@erichll/pi-sandbox 0.3.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/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +98 -0
- package/package.json +65 -0
- package/src/approval.ts +155 -0
- package/src/config.ts +121 -0
- package/src/index.ts +472 -0
- package/src/network-policy.d.mts +10 -0
- package/src/network-policy.mjs +72 -0
- package/src/policy.ts +102 -0
- package/src/runner.ts +239 -0
- package/src/srt-broker.mjs +127 -0
- package/src/subagent.ts +631 -0
- package/src/traps.ts +21 -0
package/src/runner.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fork,
|
|
3
|
+
type ChildProcess,
|
|
4
|
+
type ForkOptions,
|
|
5
|
+
} from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import type { Writable } from "node:stream";
|
|
8
|
+
import { getShellConfig } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
|
|
10
|
+
import type { NetworkEndpoint } from "./approval.ts";
|
|
11
|
+
import {
|
|
12
|
+
createDefaultPolicy,
|
|
13
|
+
toSandboxRuntimeConfig,
|
|
14
|
+
type SandboxPolicy,
|
|
15
|
+
} from "./policy.ts";
|
|
16
|
+
import type {
|
|
17
|
+
SandboxApprovalAction,
|
|
18
|
+
SandboxApprovalTrap,
|
|
19
|
+
} from "./traps.ts";
|
|
20
|
+
|
|
21
|
+
export type SandboxCommandOptions = {
|
|
22
|
+
command: string;
|
|
23
|
+
cwd: string;
|
|
24
|
+
env?: NodeJS.ProcessEnv;
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
timeout?: number;
|
|
27
|
+
onData(data: Buffer): void;
|
|
28
|
+
onStdout?(data: Buffer): void;
|
|
29
|
+
onStderr?(data: Buffer): void;
|
|
30
|
+
/**
|
|
31
|
+
* Kept for API compatibility with the approval adapter. Sandbox Runtime
|
|
32
|
+
* does not expose a trustworthy filesystem ask callback, so filesystem
|
|
33
|
+
* misses fail closed instead of reaching this callback.
|
|
34
|
+
*/
|
|
35
|
+
review(trap: SandboxApprovalTrap): Promise<SandboxApprovalAction>;
|
|
36
|
+
reviewDomain?(
|
|
37
|
+
endpoint: NetworkEndpoint,
|
|
38
|
+
): Promise<SandboxApprovalAction>;
|
|
39
|
+
policy?: SandboxPolicy;
|
|
40
|
+
shellPath?: string;
|
|
41
|
+
directInvocation?: {
|
|
42
|
+
command: string;
|
|
43
|
+
args: string[];
|
|
44
|
+
};
|
|
45
|
+
onStart?(stdin: Writable): void;
|
|
46
|
+
/**
|
|
47
|
+
* Test seam. Production callers leave this unset.
|
|
48
|
+
*/
|
|
49
|
+
broker?: {
|
|
50
|
+
modulePath: string;
|
|
51
|
+
execArgv?: string[];
|
|
52
|
+
};
|
|
53
|
+
platform?: NodeJS.Platform;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type BrokerInitMessage = {
|
|
57
|
+
type: "init";
|
|
58
|
+
invocation: string[];
|
|
59
|
+
runtimeConfig: SandboxRuntimeConfig;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type BrokerNetworkRequest = {
|
|
63
|
+
type: "network-request";
|
|
64
|
+
id: string;
|
|
65
|
+
hostname: string;
|
|
66
|
+
port: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type BrokerNetworkResponse = {
|
|
70
|
+
type: "network-response";
|
|
71
|
+
id: string;
|
|
72
|
+
action: SandboxApprovalAction;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const BROKER_MODULE = fileURLToPath(
|
|
76
|
+
new URL("./srt-broker.mjs", import.meta.url),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
function killProcessTree(child: ChildProcess): void {
|
|
80
|
+
if (child.pid === undefined) return;
|
|
81
|
+
try {
|
|
82
|
+
process.kill(-child.pid, "SIGKILL");
|
|
83
|
+
} catch {
|
|
84
|
+
child.kill("SIGKILL");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function commandInvocation(options: SandboxCommandOptions): {
|
|
89
|
+
argv: string[];
|
|
90
|
+
commandFromStdin: boolean;
|
|
91
|
+
} {
|
|
92
|
+
if (options.directInvocation) {
|
|
93
|
+
return {
|
|
94
|
+
argv: [
|
|
95
|
+
options.directInvocation.command,
|
|
96
|
+
...options.directInvocation.args,
|
|
97
|
+
],
|
|
98
|
+
commandFromStdin: false,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const shell = getShellConfig(options.shellPath);
|
|
102
|
+
const commandFromStdin = shell.commandTransport === "stdin";
|
|
103
|
+
return {
|
|
104
|
+
argv: commandFromStdin
|
|
105
|
+
? [shell.shell, ...shell.args]
|
|
106
|
+
: [shell.shell, ...shell.args, options.command],
|
|
107
|
+
commandFromStdin,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function spawnBroker(options: SandboxCommandOptions): ChildProcess {
|
|
112
|
+
const broker = options.broker ?? {
|
|
113
|
+
modulePath: BROKER_MODULE,
|
|
114
|
+
execArgv: [],
|
|
115
|
+
};
|
|
116
|
+
const forkOptions: ForkOptions = {
|
|
117
|
+
cwd: options.cwd,
|
|
118
|
+
detached: true,
|
|
119
|
+
env: options.env ?? process.env,
|
|
120
|
+
execArgv: broker.execArgv ?? [],
|
|
121
|
+
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
|
122
|
+
};
|
|
123
|
+
return fork(broker.modulePath, [], forkOptions);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function runSandboxedCommand(
|
|
127
|
+
options: SandboxCommandOptions,
|
|
128
|
+
): Promise<{ exitCode: number | null }> {
|
|
129
|
+
const platform = options.platform ?? process.platform;
|
|
130
|
+
if (platform !== "linux" && platform !== "darwin") {
|
|
131
|
+
throw new Error(`pi-sandbox does not support ${platform}`);
|
|
132
|
+
}
|
|
133
|
+
if (options.signal?.aborted) throw new Error("aborted");
|
|
134
|
+
|
|
135
|
+
const invocation = commandInvocation(options);
|
|
136
|
+
const policy = options.policy ?? createDefaultPolicy(options.cwd);
|
|
137
|
+
const broker = spawnBroker(options);
|
|
138
|
+
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
139
|
+
let timedOut = false;
|
|
140
|
+
let onAbort: (() => void) | undefined;
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
broker.stdout?.on("data", (data: Buffer) => {
|
|
144
|
+
options.onData(data);
|
|
145
|
+
options.onStdout?.(data);
|
|
146
|
+
});
|
|
147
|
+
broker.stderr?.on("data", (data: Buffer) => {
|
|
148
|
+
options.onData(data);
|
|
149
|
+
options.onStderr?.(data);
|
|
150
|
+
});
|
|
151
|
+
broker.on("message", (message: unknown) => {
|
|
152
|
+
if (!isNetworkRequest(message)) return;
|
|
153
|
+
void answerNetworkRequest(broker, message, options);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const init: BrokerInitMessage = {
|
|
157
|
+
type: "init",
|
|
158
|
+
invocation: invocation.argv,
|
|
159
|
+
runtimeConfig: toSandboxRuntimeConfig(policy),
|
|
160
|
+
};
|
|
161
|
+
broker.send?.(init);
|
|
162
|
+
|
|
163
|
+
if (invocation.commandFromStdin) {
|
|
164
|
+
broker.stdin?.on("error", () => undefined);
|
|
165
|
+
broker.stdin?.end(options.command);
|
|
166
|
+
} else if (options.directInvocation && broker.stdin) {
|
|
167
|
+
broker.stdin.on("error", () => undefined);
|
|
168
|
+
options.onStart?.(broker.stdin);
|
|
169
|
+
} else {
|
|
170
|
+
broker.stdin?.end();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
onAbort = () => killProcessTree(broker);
|
|
174
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
175
|
+
if (options.timeout !== undefined && options.timeout > 0) {
|
|
176
|
+
timeoutHandle = setTimeout(() => {
|
|
177
|
+
timedOut = true;
|
|
178
|
+
killProcessTree(broker);
|
|
179
|
+
}, options.timeout * 1_000);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const exitCode = await new Promise<number | null>((resolve, reject) => {
|
|
183
|
+
broker.once("error", reject);
|
|
184
|
+
broker.once("exit", resolve);
|
|
185
|
+
});
|
|
186
|
+
if (options.signal?.aborted) throw new Error("aborted");
|
|
187
|
+
if (timedOut) throw new Error(`timeout:${options.timeout}`);
|
|
188
|
+
return { exitCode };
|
|
189
|
+
} finally {
|
|
190
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
191
|
+
if (onAbort) options.signal?.removeEventListener("abort", onAbort);
|
|
192
|
+
if (broker.exitCode === null && broker.signalCode === null) {
|
|
193
|
+
killProcessTree(broker);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isNetworkRequest(value: unknown): value is BrokerNetworkRequest {
|
|
199
|
+
if (typeof value !== "object" || value === null) return false;
|
|
200
|
+
const message = value as Record<string, unknown>;
|
|
201
|
+
return (
|
|
202
|
+
message.type === "network-request" &&
|
|
203
|
+
typeof message.id === "string" &&
|
|
204
|
+
typeof message.hostname === "string" &&
|
|
205
|
+
typeof message.port === "number"
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function answerNetworkRequest(
|
|
210
|
+
broker: ChildProcess,
|
|
211
|
+
request: BrokerNetworkRequest,
|
|
212
|
+
options: SandboxCommandOptions,
|
|
213
|
+
): Promise<void> {
|
|
214
|
+
let action: SandboxApprovalAction = "deny";
|
|
215
|
+
try {
|
|
216
|
+
action =
|
|
217
|
+
(await options.reviewDomain?.({
|
|
218
|
+
hostname: request.hostname,
|
|
219
|
+
port: request.port,
|
|
220
|
+
protocol: "tcp",
|
|
221
|
+
})) ?? "deny";
|
|
222
|
+
} catch (error) {
|
|
223
|
+
options.onData(
|
|
224
|
+
Buffer.from(
|
|
225
|
+
`pi-sandbox: network approval failed for ${request.hostname}:${request.port}: ${
|
|
226
|
+
error instanceof Error ? error.message : String(error)
|
|
227
|
+
}\n`,
|
|
228
|
+
"utf8",
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (!broker.connected) return;
|
|
233
|
+
const response: BrokerNetworkResponse = {
|
|
234
|
+
type: "network-response",
|
|
235
|
+
id: request.id,
|
|
236
|
+
action,
|
|
237
|
+
};
|
|
238
|
+
broker.send?.(response);
|
|
239
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
|
|
5
|
+
import { validatePublicHostname } from "./network-policy.mjs";
|
|
6
|
+
|
|
7
|
+
const pendingNetwork = new Map();
|
|
8
|
+
let target;
|
|
9
|
+
|
|
10
|
+
process.on("message", (message) => {
|
|
11
|
+
if (!isNetworkResponse(message)) return;
|
|
12
|
+
const resolve = pendingNetwork.get(message.id);
|
|
13
|
+
if (!resolve) return;
|
|
14
|
+
pendingNetwork.delete(message.id);
|
|
15
|
+
resolve(message.action === "allow");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
process.once("disconnect", () => {
|
|
19
|
+
if (target?.pid !== undefined) {
|
|
20
|
+
try {
|
|
21
|
+
process.kill(target.pid, "SIGKILL");
|
|
22
|
+
} catch {
|
|
23
|
+
// The target already exited.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function isInitMessage(value) {
|
|
29
|
+
return (
|
|
30
|
+
typeof value === "object" &&
|
|
31
|
+
value !== null &&
|
|
32
|
+
value.type === "init" &&
|
|
33
|
+
Array.isArray(value.invocation) &&
|
|
34
|
+
value.invocation.length > 0 &&
|
|
35
|
+
value.invocation.every((item) => typeof item === "string") &&
|
|
36
|
+
typeof value.runtimeConfig === "object" &&
|
|
37
|
+
value.runtimeConfig !== null
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isNetworkResponse(value) {
|
|
42
|
+
return (
|
|
43
|
+
typeof value === "object" &&
|
|
44
|
+
value !== null &&
|
|
45
|
+
value.type === "network-response" &&
|
|
46
|
+
typeof value.id === "string" &&
|
|
47
|
+
(value.action === "allow" || value.action === "deny")
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function shellQuote(value) {
|
|
52
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function askNetwork(hostname, port) {
|
|
56
|
+
if (!process.send || !process.connected) return false;
|
|
57
|
+
const normalized = await validatePublicHostname(hostname);
|
|
58
|
+
if (!normalized) return false;
|
|
59
|
+
const id = randomUUID();
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
pendingNetwork.set(id, resolve);
|
|
62
|
+
process.send({
|
|
63
|
+
type: "network-request",
|
|
64
|
+
id,
|
|
65
|
+
hostname: normalized,
|
|
66
|
+
port,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function main() {
|
|
72
|
+
const [message] = await once(process, "message");
|
|
73
|
+
if (!isInitMessage(message)) {
|
|
74
|
+
throw new Error("received an invalid broker initialization message");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await SandboxManager.initialize(
|
|
78
|
+
message.runtimeConfig,
|
|
79
|
+
({ host, port }) => askNetwork(host, port ?? 443),
|
|
80
|
+
false,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const command = message.invocation.map(shellQuote).join(" ");
|
|
84
|
+
const wrapped = await SandboxManager.wrapWithSandboxArgv(
|
|
85
|
+
command,
|
|
86
|
+
"/bin/bash",
|
|
87
|
+
undefined,
|
|
88
|
+
undefined,
|
|
89
|
+
process.cwd(),
|
|
90
|
+
);
|
|
91
|
+
target = spawn(wrapped.argv[0], wrapped.argv.slice(1), {
|
|
92
|
+
cwd: process.cwd(),
|
|
93
|
+
env: wrapped.env,
|
|
94
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
95
|
+
});
|
|
96
|
+
process.stdin.pipe(target.stdin);
|
|
97
|
+
target.stdout.pipe(process.stdout);
|
|
98
|
+
target.stderr.pipe(process.stderr);
|
|
99
|
+
|
|
100
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
101
|
+
target.once("error", reject);
|
|
102
|
+
target.once("exit", resolve);
|
|
103
|
+
});
|
|
104
|
+
SandboxManager.cleanupAfterCommand();
|
|
105
|
+
await SandboxManager.reset();
|
|
106
|
+
process.exitCode = exitCode ?? 1;
|
|
107
|
+
process.stdin.unpipe();
|
|
108
|
+
process.stdin.pause();
|
|
109
|
+
process.disconnect();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
main().catch(async (error) => {
|
|
113
|
+
process.stderr.write(
|
|
114
|
+
`pi-sandbox: Sandbox Runtime broker failed: ${
|
|
115
|
+
error instanceof Error ? error.message : String(error)
|
|
116
|
+
}\n`,
|
|
117
|
+
);
|
|
118
|
+
try {
|
|
119
|
+
await SandboxManager.reset();
|
|
120
|
+
} catch {
|
|
121
|
+
// Best-effort cleanup after initialization failures.
|
|
122
|
+
}
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
process.stdin.unpipe();
|
|
125
|
+
process.stdin.pause();
|
|
126
|
+
if (process.connected) process.disconnect();
|
|
127
|
+
});
|