@aspectlylabs/mikka 0.0.230 → 0.0.231

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.
@@ -1,360 +0,0 @@
1
- // This trusted host process owns exactly one SandboxManager and one CLI.
2
- // Only its parent's private IPC channel can configure it or answer a request.
3
- import { spawn } from "node:child_process"
4
- import { randomUUID } from "node:crypto"
5
- import { isIP, Socket } from "node:net"
6
- import { isAbsolute } from "node:path"
7
- import { fileURLToPath } from "node:url"
8
-
9
- const AUTH_LIMIT = 1_048_576
10
- const MAX_PENDING_NETWORK = 128
11
- const NETWORK_DECISION_MS = 5 * 60_000
12
-
13
- export function assertRunSandboxDependencies(dependencies, platform = process.platform) {
14
- if (dependencies.errors.length) {
15
- throw new Error(`sandbox dependencies unavailable: ${dependencies.errors.join("; ")}`)
16
- }
17
- // Official 0.0.80 reports a missing apply-seccomp helper as only a warning.
18
- // That would leave host Unix sockets reachable, so this launcher refuses to
19
- // initialize or wrap. Old/unknown Linux dependency warnings also fail closed.
20
- if (platform === "linux" && dependencies.warnings.length) {
21
- throw new Error(`sandbox isolation dependencies unsafe: ${dependencies.warnings.join("; ")}`)
22
- }
23
- }
24
-
25
- // Runs INSIDE the official sandbox. Linux bwrap deliberately closes extra
26
- // inherited fds, so a framed stdin prefix carries the token across that
27
- // boundary. It is converted to a token-only fd3 pipe before the real CLI starts.
28
- async function authBridge() {
29
- let token
30
- let child
31
- let headerBytes = 0
32
- let tokenBytes = 0
33
- const header = Buffer.alloc(4)
34
- let complete = false
35
- const fail = () => {
36
- token?.fill(0)
37
- process.stderr.write("run sandbox auth pipe was incomplete or invalid\n")
38
- process.exit(1)
39
- }
40
- process.stdin.on("error", fail)
41
- process.stdin.once("end", () => { if (!complete) fail() })
42
- const read = (chunk) => {
43
- let offset = 0
44
- if (headerBytes < 4) {
45
- const count = Math.min(4 - headerBytes, chunk.length)
46
- chunk.copy(header, headerBytes, 0, count)
47
- headerBytes += count
48
- offset += count
49
- if (headerBytes === 4) {
50
- const length = header.readUInt32BE()
51
- if (length < 1 || length > AUTH_LIMIT) return fail()
52
- token = Buffer.alloc(length)
53
- }
54
- }
55
- if (token) {
56
- const count = Math.min(token.length - tokenBytes, chunk.length - offset)
57
- chunk.copy(token, tokenBytes, offset, offset + count)
58
- chunk.fill(0, offset, offset + count)
59
- tokenBytes += count
60
- offset += count
61
- }
62
- if (!token || tokenBytes !== token.length) return
63
- complete = true
64
- process.stdin.pause()
65
- process.stdin.off("data", read)
66
- if (offset < chunk.length) process.stdin.unshift(chunk.subarray(offset))
67
- child = spawn(process.argv[3], process.argv.slice(4), {
68
- env: { ...process.env, CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: "3" },
69
- stdio: ["pipe", "pipe", "pipe", "pipe"],
70
- })
71
- child.once("error", () => fail())
72
- child.stdin.on("error", () => undefined)
73
- child.stdio[3].on("error", fail)
74
- const payload = token
75
- child.stdio[3].end(payload, () => { payload.fill(0); token = undefined })
76
- // unshift above preserves protocol bytes delivered in the frame's last
77
- // chunk. pipe handles subsequent data and backpressure without parsing it.
78
- process.stdin.pipe(child.stdin)
79
- child.stdout.pipe(process.stdout, { end: false })
80
- child.stderr.pipe(process.stderr, { end: false })
81
- child.once("close", async (code, signal) => {
82
- token?.fill(0)
83
- await Promise.all([process.stdout, process.stderr].map((stream) =>
84
- new Promise((resolve) => stream.write("", resolve))))
85
- process.exit(code ?? (signal === "SIGINT" ? 130 : 143))
86
- })
87
- }
88
- process.stdin.on("data", read)
89
- }
90
-
91
- async function runWorker() {
92
- const { NetworkConfigSchema, SandboxManager, SandboxRuntimeConfigSchema } = await import("@anthropic-ai/sandbox-runtime")
93
-
94
- let configured = false
95
- let active = false
96
- let stopping = false
97
- let cli
98
- let initialize
99
- let wrapping
100
- let nextRequest = 0
101
- let stderr = ""
102
- let correlation
103
- let initTimer
104
- const pending = new Map()
105
- const lifetime = new AbortController()
106
-
107
- // Intentionally kept equivalent to the parent's exported matcher. Both sides
108
- // independently apply the immutable maximum before a permission callback runs.
109
- function canonicalHost(host) {
110
- // eslint-disable-next-line no-control-regex -- Reject control bytes in a destination.
111
- if (!host || /[\s\x00-\x1f\x7f%\\/@?#*]/u.test(host)) return
112
- const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host
113
- if (isIP(bare) === 6) return new URL(`http://[${bare}]`).hostname.slice(1, -1)
114
- if (bare.includes(":")) return
115
- try { return new URL(`http://${bare}`).hostname.toLowerCase() } catch { return undefined }
116
- }
117
-
118
- function matchesDomain(request, domains) {
119
- const host = canonicalHost(request.host)
120
- if (!host || !Number.isInteger(request.port) || request.port < 1 || request.port > 65535) return false
121
- return domains.some((entry) => {
122
- if (entry === "*") return true
123
- const parts = /^(\[[^\]]+\]|[^:]+?)(?::([1-9][0-9]{0,4}))?$/u.exec(entry)
124
- if (!parts || (parts[2] !== undefined &&
125
- (Number(parts[2]) > 65535 || Number(parts[2]) !== request.port))) return false
126
- const wildcard = parts[1].startsWith("*.")
127
- const pattern = canonicalHost(wildcard ? parts[1].slice(2) : parts[1])
128
- if (!pattern) return false
129
- return wildcard ? !isIP(host) && pattern.includes(".") && host.endsWith(`.${pattern}`) : host === pattern
130
- })
131
- }
132
-
133
- function quote(arg) {
134
- return /^[A-Za-z0-9_@%+=:,./-]+$/u.test(arg) ? arg : `'${arg.replaceAll("'", `'"'"'`)}'`
135
- }
136
-
137
- function send(message) {
138
- if (!process.connected) return
139
- process.send(message, (error) => { if (error) void stop(0) })
140
- }
141
-
142
- function denyPending() {
143
- active = false
144
- lifetime.abort()
145
- for (const request of pending.values()) {
146
- clearTimeout(request.timer)
147
- request.resolve(false)
148
- }
149
- pending.clear()
150
- }
151
-
152
- function groupSignal(signal) {
153
- try { process.kill(-process.pid, signal) } catch (error) {
154
- if (error.code !== "ESRCH") throw error
155
- }
156
- }
157
-
158
- async function stop(graceMs = 0, outcome = { code: null, signal: "SIGTERM" }) {
159
- denyPending()
160
- if (stopping) return
161
- stopping = true
162
- clearTimeout(initTimer)
163
- // The worker catches TERM; CLI descendants and runtime helpers share its
164
- // group. A final parent group kill also covers a descendant that ignores it.
165
- const watchdog = setTimeout(() => {
166
- process.stderr.write("run sandbox teardown timed out; terminating process group\n")
167
- groupSignal("SIGKILL")
168
- }, Math.max(0, Math.min(graceMs, 30_000)) + 4000)
169
- try {
170
- groupSignal("SIGTERM")
171
- if (cli && cli.exitCode === null && cli.signalCode === null) {
172
- // Linux bwrap creates a new session for its PID namespace. Address the
173
- // owned wrapper child directly as well as our group; --die-with-parent
174
- // and --unshare-pid end its nested workload when that wrapper dies.
175
- cli.kill(graceMs > 0 ? "SIGTERM" : "SIGKILL")
176
- await new Promise((resolve) => {
177
- const timer = setTimeout(() => { cli.kill("SIGKILL") }, Math.min(graceMs, 30_000))
178
- cli.once("exit", () => { clearTimeout(timer); resolve() })
179
- })
180
- }
181
- await initialize?.catch(() => undefined)
182
- await wrapping?.catch(() => undefined)
183
- // Official reset closes this launcher's established proxy connections and
184
- // removes only its own ephemeral sockets/mountpoints/CA/masked artifacts.
185
- // No worktree, run directory, logs or caller-owned data are removed here.
186
- await SandboxManager.reset()
187
- if (correlation) {
188
- const annotated = SandboxManager.annotateStderrWithSandboxFailures(correlation, stderr)
189
- if (annotated.length > stderr.length) process.stderr.write(annotated.slice(stderr.length))
190
- }
191
- if (process.connected) await new Promise((resolve) => {
192
- process.send({ type: "finished", ...outcome }, () => resolve())
193
- })
194
- // process.exit does not wait for writes; flush the bridged output first.
195
- await Promise.all([process.stdout, process.stderr].map((stream) =>
196
- new Promise((resolve) => stream.write("", resolve))))
197
- process.exit(outcome.code ?? (outcome.signal === "SIGINT" ? 130 : 143))
198
- } catch (error) {
199
- process.stderr.write(`run sandbox teardown failed: ${error.message}\n`)
200
- // A failed reset cannot be followed by a successful-looking exit or a
201
- // lingering live proxy. Group kill is fail-closed; parent also enforces it.
202
- groupSignal("SIGKILL")
203
- } finally {
204
- clearTimeout(watchdog)
205
- }
206
- }
207
-
208
- async function start(message) {
209
- try {
210
- if (typeof message.command !== "string" || !message.command || message.command.includes("\0") ||
211
- !Array.isArray(message.args) || message.args.some((arg) => typeof arg !== "string" || arg.includes("\0")) ||
212
- typeof message.cwd !== "string" || !isAbsolute(message.cwd) ||
213
- !message.env || typeof message.env !== "object" || Array.isArray(message.env) ||
214
- Object.entries(message.env).some(([key, value]) => key.includes("=") || key.includes("\0") ||
215
- (value !== undefined && (typeof value !== "string" || value.includes("\0")))) ||
216
- !Array.isArray(message.maxAllowDomains) || !Array.isArray(message.modelApiDomains) ||
217
- typeof message.hasAuthToken !== "boolean") {
218
- throw new Error("invalid run sandbox launch configuration")
219
- }
220
- const maximum = Object.freeze([...message.maxAllowDomains])
221
- NetworkConfigSchema.parse({ allowedDomains: maximum.filter((entry) => entry !== "*"), deniedDomains: [] })
222
- NetworkConfigSchema.parse({ allowedDomains: [...message.modelApiDomains], deniedDomains: [] })
223
- const config = SandboxRuntimeConfigSchema.parse({
224
- // Every connection, even a model API connection, must consult the live
225
- // parent closure. No configured allow or command-id allow bypasses it.
226
- network: {
227
- allowedDomains: [], deniedDomains: [], strictAllowlist: false,
228
- allowLocalBinding: false, allowAllUnixSockets: false, allowUnixSockets: [],
229
- },
230
- filesystem: message.policy,
231
- enableWeakerNetworkIsolation: true,
232
- enableWeakerNestedSandbox: false,
233
- allowNestedUserNamespaces: false,
234
- })
235
- if (!SandboxManager.isSupportedPlatform()) throw new Error("sandbox platform unsupported")
236
- const dependencies = SandboxManager.checkDependencies()
237
- assertRunSandboxDependencies(dependencies)
238
- if (stopping || !process.connected) return
239
- active = true
240
- initialize = SandboxManager.initialize(config, async (request) => {
241
- if (!active || stopping || !process.connected || pending.size >= MAX_PENDING_NETWORK || !matchesDomain(request, maximum)) return false
242
- const id = ++nextRequest
243
- return new Promise((resolve) => {
244
- const timer = setTimeout(() => {
245
- if (pending.delete(id)) resolve(false)
246
- }, NETWORK_DECISION_MS)
247
- pending.set(id, { resolve, timer })
248
- send({ type: "network", id, host: request.host, port: request.port })
249
- })
250
- })
251
- await initialize
252
- if (!active || stopping || !process.connected) return
253
- const argv = message.hasAuthToken
254
- ? [process.execPath, fileURLToPath(import.meta.url), "--auth-bridge", message.command, ...message.args]
255
- : [message.command, ...message.args]
256
- const command = argv.map(quote).join(" ")
257
- // Correlation is only for diagnostics, never network/run authority.
258
- correlation = randomUUID()
259
- wrapping = SandboxManager.wrapWithSandbox(command, "/bin/sh", undefined, lifetime.signal, { commandId: correlation })
260
- const wrapped = await wrapping
261
- if (!active || stopping || !process.connected) return
262
- const env = { ...message.env }
263
- // Node's private IPC setup must not be inherited by the untrusted CLI.
264
- delete env.NODE_CHANNEL_FD
265
- delete env.NODE_CHANNEL_SERIALIZATION_MODE
266
- if (message.hasAuthToken === true) {
267
- env.CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR = "3"
268
- delete env.CLAUDE_CODE_OAUTH_TOKEN
269
- }
270
- cli = spawn("/bin/sh", ["-c", `exec ${wrapped}`], {
271
- cwd: message.cwd,
272
- env,
273
- stdio: ["pipe", "pipe", "pipe"],
274
- // Stay in the launcher's group, so even an abrupt worker failure cannot
275
- // orphan an independently detached CLI before its pid can be reported.
276
- detached: false,
277
- })
278
- cli.stdin.on("error", () => undefined)
279
- if (message.hasAuthToken) {
280
- // The token arrives on a separate host pipe. Frame it before forwarding
281
- // any protocol input, so neither packet boundaries nor early SDK writes
282
- // can mix credentials with the first CLI message.
283
- const source = new Socket({ fd: 3, readable: true, writable: false })
284
- const chunks = []
285
- let length = 0
286
- source.on("data", (data) => {
287
- length += data.length
288
- if (length > AUTH_LIMIT) {
289
- data.fill(0)
290
- source.destroy(new Error("auth pipe payload too large"))
291
- } else chunks.push(data)
292
- })
293
- source.once("error", () => {
294
- for (const chunk of chunks) chunk.fill(0)
295
- process.stderr.write("run sandbox auth pipe failed\n")
296
- void stop(0, { code: 1, signal: null })
297
- })
298
- source.once("end", () => {
299
- if (!length || stopping) {
300
- for (const chunk of chunks) chunk.fill(0)
301
- void stop(0, { code: 1, signal: null })
302
- return
303
- }
304
- const payload = Buffer.concat(chunks)
305
- for (const chunk of chunks) chunk.fill(0)
306
- const header = Buffer.alloc(4)
307
- header.writeUInt32BE(payload.length)
308
- cli.stdin.write(header)
309
- cli.stdin.write(payload, () => payload.fill(0))
310
- process.stdin.pipe(cli.stdin)
311
- })
312
- } else process.stdin.pipe(cli.stdin)
313
- cli.stdout.pipe(process.stdout, { end: false })
314
- cli.stderr.on("data", (data) => { stderr = (stderr + data.toString()).slice(-16_000) })
315
- cli.stderr.pipe(process.stderr, { end: false })
316
- cli.once("error", (error) => {
317
- process.stderr.write(`sandboxed CLI launch failed: ${error.message}\n`)
318
- void stop(0, { code: 1, signal: null })
319
- })
320
- cli.once("close", (code, signal) => { void stop(0, { code, signal }) })
321
- } catch (error) {
322
- process.stderr.write(`run sandbox launch failed: ${error.message}\n`)
323
- await stop(0, { code: 1, signal: null })
324
- }
325
- }
326
-
327
- process.on("message", (message) => {
328
- if (!message || typeof message !== "object") return
329
- if (message.type === "stop") {
330
- void stop(Number.isFinite(message.graceMs) ? Math.max(0, message.graceMs) : 0)
331
- } else if (message.type === "init" && !configured && !stopping) {
332
- configured = true
333
- clearTimeout(initTimer)
334
- void start(message)
335
- } else if (message.type === "decision" && Number.isSafeInteger(message.id)) {
336
- const request = pending.get(message.id)
337
- if (!request) return
338
- pending.delete(message.id)
339
- clearTimeout(request.timer)
340
- request.resolve(message.allow === true && active && !stopping && process.connected)
341
- }
342
- })
343
- process.on("disconnect", () => { void stop(0) })
344
- process.on("SIGTERM", () => { void stop(0) })
345
- process.on("SIGINT", () => { void stop(0, { code: null, signal: "SIGINT" }) })
346
- process.stdout.on("error", () => { void stop(0) })
347
- process.stderr.on("error", () => { void stop(0) })
348
- initTimer = setTimeout(() => {
349
- process.stderr.write("run sandbox launch configuration was not received\n")
350
- void stop(0, { code: 1, signal: null })
351
- }, 10_000)
352
- if (!process.connected) void stop(0, { code: 1, signal: null })
353
- }
354
-
355
- // Importing the pure dependency check for source-only tests does not start a
356
- // launcher, touch resources or initialize the official manager.
357
- if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
358
- if (process.argv[2] === "--auth-bridge") await authBridge()
359
- else await runWorker()
360
- }