@themoltnet/sandbox-gondolin 0.2.0 → 0.4.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/README.md +7 -9
- package/dist/index.d.ts +132 -80
- package/dist/index.js +363 -198
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -14,10 +14,9 @@ implementation can be added beside it without going through Pi.
|
|
|
14
14
|
with VFS shadowing (`deny` / `tmpfs`), egress allowlist through Gondolin's
|
|
15
15
|
host-side HTTP hooks, explicit guest environment, resource limits, resume
|
|
16
16
|
commands, and abort-safe cleanup.
|
|
17
|
-
- The guest credential boundary:
|
|
18
|
-
`.moltnet/<agent>` tree
|
|
19
|
-
|
|
20
|
-
(`assertGuestEnvironmentBoundary`).
|
|
17
|
+
- The guest credential boundary: the guest never receives the
|
|
18
|
+
`.moltnet/<agent>` tree, and `MOLTNET_*` names are refused in
|
|
19
|
+
runtime-controlled guest environment (`assertGuestEnvironmentBoundary`).
|
|
21
20
|
- `VmConfig.brokeredSecrets` — host-brokered secrets: the guest sees a
|
|
22
21
|
placeholder, Gondolin's `SecretManager` substitutes the value only in
|
|
23
22
|
requests to the declared protocol, host patterns, and ports.
|
|
@@ -27,10 +26,10 @@ implementation can be added beside it without going through Pi.
|
|
|
27
26
|
|
|
28
27
|
## What it deliberately does not own
|
|
29
28
|
|
|
30
|
-
- **Provider authentication.**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
- **Provider authentication.** The coding-agent session and its model calls run
|
|
30
|
+
host-side (Pi's `createAgentSession` reads the host `~/.pi/agent` auth), so no
|
|
31
|
+
provider auth is projected into the guest — the guest only executes tools via
|
|
32
|
+
`vm.exec`.
|
|
34
33
|
- Tool definitions, tool policy, task execution, workspace preparation —
|
|
35
34
|
all Pi-runtime concerns.
|
|
36
35
|
|
|
@@ -54,7 +53,6 @@ contain a value or choose a host secret-provider coordinate.
|
|
|
54
53
|
const managed = await resumeVm({
|
|
55
54
|
checkpointPath,
|
|
56
55
|
agentName: 'worker',
|
|
57
|
-
guestCredentialMode: 'host-authenticated',
|
|
58
56
|
mountPath: workspace,
|
|
59
57
|
sandboxConfig: {
|
|
60
58
|
network: { allowedHosts: ['api.example.com'] },
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HttpIpAllowInfo } from '@earendil-works/gondolin';
|
|
1
2
|
import { MemoryProvider } from '@earendil-works/gondolin';
|
|
2
3
|
import { SecretDefinition } from '@earendil-works/gondolin';
|
|
3
4
|
import { VirtualFileHandle } from '@earendil-works/gondolin';
|
|
@@ -20,12 +21,11 @@ declare interface AbortableResourceOptions<T> {
|
|
|
20
21
|
export declare function activateAgentEnv(agentEnv: Record<string, string | undefined>, repoRoot: string): void;
|
|
21
22
|
|
|
22
23
|
export declare function assertGuestEnvironmentBoundary(options: {
|
|
23
|
-
guestCredentialMode: GuestCredentialMode;
|
|
24
24
|
forwardEnv?: readonly string[];
|
|
25
25
|
sandboxEnv?: Readonly<Record<string, string>>;
|
|
26
26
|
}): void;
|
|
27
27
|
|
|
28
|
-
/** @deprecated
|
|
28
|
+
/** @deprecated Alias of assertGuestEnvironmentBoundary (a single boundary). */
|
|
29
29
|
export declare function assertHostAuthenticatedGuestEnvironment(options: {
|
|
30
30
|
forwardEnv?: readonly string[];
|
|
31
31
|
sandboxEnv?: Readonly<Record<string, string>>;
|
|
@@ -85,6 +85,20 @@ export declare interface BrokeredHttpSecretManager {
|
|
|
85
85
|
*/
|
|
86
86
|
export declare function canonicalizeBrokeredHttpSecretDescriptor(descriptor: BrokeredHttpSecretDescriptor): Required<BrokeredHttpSecretDescriptor>;
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Refine Gondolin's hostname allowlist for hosts carrying brokered
|
|
90
|
+
* credentials. The built-in allowlist is hostname-granular; these callbacks
|
|
91
|
+
* enforce the descriptor protocol and port before request or IP dispatch.
|
|
92
|
+
*/
|
|
93
|
+
export declare function createBrokeredHttpNetworkOriginPolicy(bindings: readonly BrokeredHttpSecretDescriptor[], options?: {
|
|
94
|
+
onDenied?: (denial: RequestOrigin & {
|
|
95
|
+
phase: 'request' | 'ip';
|
|
96
|
+
}) => void;
|
|
97
|
+
}): {
|
|
98
|
+
isRequestAllowed: (request: Request) => boolean;
|
|
99
|
+
isIpAllowed: (info: HttpIpAllowInfo) => boolean;
|
|
100
|
+
};
|
|
101
|
+
|
|
88
102
|
export declare function delay(ms: number, signal: AbortSignal | undefined, label: string): Promise<void>;
|
|
89
103
|
|
|
90
104
|
/**
|
|
@@ -100,6 +114,14 @@ export declare interface EnsureSnapshotOptions {
|
|
|
100
114
|
maxCached?: number;
|
|
101
115
|
}
|
|
102
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Execute one login-shell command in a Gondolin VM. Successful commands may
|
|
119
|
+
* reuse the VM. Timeout or cancellation retires the complete VM through the
|
|
120
|
+
* host API before returning, because no guest process/session boundary can
|
|
121
|
+
* prove containment against daemonization or a guest-mutated root filesystem.
|
|
122
|
+
*/
|
|
123
|
+
export declare function execManagedCommand(vm: VM, command: string, options?: ManagedExecOptions): Promise<ManagedExecResult>;
|
|
124
|
+
|
|
103
125
|
/**
|
|
104
126
|
* Resolve the main worktree root (where .moltnet/ lives — it's untracked,
|
|
105
127
|
* only exists in the main worktree, not in git worktrees).
|
|
@@ -109,6 +131,9 @@ export declare function findMainWorktree(startPath?: string): string;
|
|
|
109
131
|
/** Commands guaranteed by the base Gondolin snapshot. */
|
|
110
132
|
export declare const GONDOLIN_BASE_EXECUTABLES: readonly string[];
|
|
111
133
|
|
|
134
|
+
/** Guest directory holding one PID file per projected service. */
|
|
135
|
+
export declare const GUEST_SERVICE_PID_DIR = "/run/moltnet/services";
|
|
136
|
+
|
|
112
137
|
/**
|
|
113
138
|
* Memory-backed VFS mount used by the daemon to inject task context
|
|
114
139
|
* (#943 slice 1.5). This is a separate top-level mount because Gondolin
|
|
@@ -134,13 +159,44 @@ export declare const GUEST_TASK_CONTEXT_MOUNT = "/moltnet-task-context";
|
|
|
134
159
|
/** @deprecated Use GUEST_TASK_CONTEXT_MOUNT. */
|
|
135
160
|
export declare const GUEST_TASK_SKILLS_MOUNT = "/moltnet-task-context";
|
|
136
161
|
|
|
137
|
-
export declare type GuestCredentialMode = 'guest-config' | 'host-authenticated';
|
|
138
|
-
|
|
139
162
|
export declare class GuestEnvironmentBoundaryError extends Error {
|
|
140
163
|
readonly refusedNames: readonly string[];
|
|
141
164
|
constructor(refusedNames: readonly string[]);
|
|
142
165
|
}
|
|
143
166
|
|
|
167
|
+
/** Declarative, trusted-host guest projection (env, files, services). */
|
|
168
|
+
export declare interface GuestProjectionInput {
|
|
169
|
+
env?: Record<string, string>;
|
|
170
|
+
files?: {
|
|
171
|
+
path: string;
|
|
172
|
+
content: string | Uint8Array;
|
|
173
|
+
mode?: number;
|
|
174
|
+
}[];
|
|
175
|
+
services?: {
|
|
176
|
+
id: string;
|
|
177
|
+
command: readonly string[];
|
|
178
|
+
env?: Record<string, string>;
|
|
179
|
+
/** Guest path to wait for before the session starts. */
|
|
180
|
+
readiness?: {
|
|
181
|
+
path: string;
|
|
182
|
+
timeoutMs?: number;
|
|
183
|
+
required?: boolean;
|
|
184
|
+
};
|
|
185
|
+
}[];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Lifecycle handle for projected guest services. */
|
|
189
|
+
export declare interface GuestServices {
|
|
190
|
+
stop(): Promise<void>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Host origins: hostnames the guest can reach whose responses are produced by
|
|
195
|
+
* host code inside the proxy (`onRequest` short-circuit). Nothing is listened
|
|
196
|
+
* on, nothing is forwarded; the sandbox knows only the origin → handler map.
|
|
197
|
+
*/
|
|
198
|
+
declare type HostOriginHandler = (request: Request) => Promise<Response>;
|
|
199
|
+
|
|
144
200
|
/**
|
|
145
201
|
* Check containment for already-resolved lexical or real paths.
|
|
146
202
|
*
|
|
@@ -151,13 +207,51 @@ export declare class GuestEnvironmentBoundaryError extends Error {
|
|
|
151
207
|
*/
|
|
152
208
|
export declare function isResolvedPathInsideRoot(path: string, root: string): boolean;
|
|
153
209
|
|
|
154
|
-
export declare function loadCredentials(
|
|
210
|
+
export declare function loadCredentials(): VmCredentials;
|
|
211
|
+
|
|
212
|
+
export declare interface ManagedExecDiagnostic {
|
|
213
|
+
event: 'managed_exec.backend_retirement';
|
|
214
|
+
trigger: 'cancellation' | 'timeout';
|
|
215
|
+
outcome: 'started' | 'succeeded' | 'failed';
|
|
216
|
+
message: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export declare interface ManagedExecOptions {
|
|
220
|
+
cwd?: string;
|
|
221
|
+
env?: Record<string, string>;
|
|
222
|
+
signal?: AbortSignal;
|
|
223
|
+
timeoutMs?: number;
|
|
224
|
+
onData?: (data: Buffer, stream: 'stdout' | 'stderr') => void;
|
|
225
|
+
onDiagnostic?: (diagnostic: ManagedExecDiagnostic) => void;
|
|
226
|
+
onStarted?: () => void;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export declare interface ManagedExecResult {
|
|
230
|
+
exitCode: number;
|
|
231
|
+
timedOut: boolean;
|
|
232
|
+
cancelled: boolean;
|
|
233
|
+
termination: ManagedExecTermination;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export declare type ManagedExecTermination = {
|
|
237
|
+
status: 'not-required';
|
|
238
|
+
} | {
|
|
239
|
+
status: 'not-started';
|
|
240
|
+
} | {
|
|
241
|
+
status: 'backend-retired';
|
|
242
|
+
mode: 'vm-close';
|
|
243
|
+
} | {
|
|
244
|
+
status: 'recovery-required';
|
|
245
|
+
reason: 'backend-retirement-failed';
|
|
246
|
+
};
|
|
155
247
|
|
|
156
248
|
export declare interface ManagedVm {
|
|
157
249
|
vm: VM;
|
|
158
250
|
credentials: VmCredentials;
|
|
159
251
|
/** Host-only rotation and revocation handle. It cannot widen destinations. */
|
|
160
252
|
secretManager: BrokeredHttpSecretManager;
|
|
253
|
+
/** Projected guest services; `stop()` before closing the VM. Always present. */
|
|
254
|
+
services: GuestServices;
|
|
161
255
|
mountPath: string;
|
|
162
256
|
guestWorkspace: string;
|
|
163
257
|
agentDir: string;
|
|
@@ -173,11 +267,10 @@ export declare function prepareBrokeredHttpSecrets(options: {
|
|
|
173
267
|
occupiedGuestEnvNames?: readonly string[];
|
|
174
268
|
}): Record<string, SecretDefinition>;
|
|
175
269
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
guestPath: string;
|
|
270
|
+
declare interface RequestOrigin {
|
|
271
|
+
hostname: string;
|
|
272
|
+
protocol: string;
|
|
273
|
+
port: number;
|
|
181
274
|
}
|
|
182
275
|
|
|
183
276
|
export declare function resolveVfsShadowConfig(config: SandboxConfig | undefined): {
|
|
@@ -221,44 +314,6 @@ export declare interface ResumeCommandWhen {
|
|
|
221
314
|
*/
|
|
222
315
|
export declare function resumeVm(config: VmConfig): Promise<ManagedVm>;
|
|
223
316
|
|
|
224
|
-
/**
|
|
225
|
-
* Rewrite host-absolute paths inside an agent gitconfig to VM-local
|
|
226
|
-
* equivalents before injecting it into the guest.
|
|
227
|
-
*
|
|
228
|
-
* Two rewrites:
|
|
229
|
-
* - `signingKey = <host path>` → `<vmSshDir>/id_ed25519`
|
|
230
|
-
* - `... credential-helper --credentials <host moltnet.json>`
|
|
231
|
-
* → `<vmAgentDir>/moltnet.json`
|
|
232
|
-
*
|
|
233
|
-
* The credential-helper line is generated host-side by `moltnet github setup`
|
|
234
|
-
* with a host-absolute `--credentials` path; inside the guest that path is
|
|
235
|
-
* invalid, so it must point at the VM-side moltnet.json. The `insteadOf`
|
|
236
|
-
* rewrite rule and every other line are workspace-independent and pass through
|
|
237
|
-
* unchanged. A gitconfig without a credential helper is rewritten only for
|
|
238
|
-
* `signingKey`.
|
|
239
|
-
*
|
|
240
|
-
* This is the single source of truth for git push auth in the guest: the
|
|
241
|
-
* injected gitconfig carries the tokenless mint-on-demand helper, so the VM
|
|
242
|
-
* no longer hand-rolls a credential-helper script or runs an imperative
|
|
243
|
-
* `git config --global ... insteadOf` against the guest $HOME.
|
|
244
|
-
*/
|
|
245
|
-
export declare function rewriteGitconfigPaths(gitconfig: string, vmSshDir: string, vmAgentDir: string): string;
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Rewrite host-absolute paths inside moltnet.json to VM-local equivalents.
|
|
249
|
-
*
|
|
250
|
-
* Fields rewritten:
|
|
251
|
-
* ssh.private_key_path → <vmSshDir>/<basename of original>
|
|
252
|
-
* ssh.public_key_path → <vmSshDir>/<basename of original>
|
|
253
|
-
* git.config_path → <vmAgentDir>/gitconfig
|
|
254
|
-
* github.private_key_path → <vmAgentDir>/<pemFilename> (if present)
|
|
255
|
-
*
|
|
256
|
-
* All other fields are passed through unchanged.
|
|
257
|
-
* Throws if moltnetJson is not valid JSON — callers must not inject a broken
|
|
258
|
-
* moltnet.json into the guest.
|
|
259
|
-
*/
|
|
260
|
-
export declare function rewriteMoltnetJsonPaths(moltnetJson: string, vmAgentDir: string, vmSshDir: string, githubAppPemFilename: string | null): string;
|
|
261
|
-
|
|
262
317
|
export declare interface SandboxConfig {
|
|
263
318
|
/**
|
|
264
319
|
* Operator-owned snapshot build settings. Runtime profiles must never
|
|
@@ -348,12 +403,6 @@ export declare interface VmConfig {
|
|
|
348
403
|
checkpointPath: string;
|
|
349
404
|
/** MoltNet agent name (used to resolve credentials). */
|
|
350
405
|
agentName: string;
|
|
351
|
-
/**
|
|
352
|
-
* Trust boundary for guest credentials. `guest-config` injects the complete
|
|
353
|
-
* legacy agent directory. `host-authenticated` never reads or injects it and
|
|
354
|
-
* relies exclusively on the supplied host-side Agent for MoltNet operations.
|
|
355
|
-
*/
|
|
356
|
-
guestCredentialMode?: GuestCredentialMode;
|
|
357
406
|
/**
|
|
358
407
|
* Host root that owns `.moltnet/<agentName>/`.
|
|
359
408
|
*
|
|
@@ -383,49 +432,52 @@ export declare interface VmConfig {
|
|
|
383
432
|
* raw values or select host secret-provider coordinates.
|
|
384
433
|
*/
|
|
385
434
|
brokeredSecrets?: readonly BrokeredHttpSecretBinding[];
|
|
435
|
+
/**
|
|
436
|
+
* Trusted-host origins answered in-process by the proxy. Keys are full
|
|
437
|
+
* origins (`https://name.moltnet.internal`). Like brokered secrets these are
|
|
438
|
+
* outside SandboxConfig: a remotely stored profile cannot register one.
|
|
439
|
+
*/
|
|
440
|
+
hostOrigins?: Record<string, HostOriginHandler>;
|
|
441
|
+
/**
|
|
442
|
+
* Trusted-host guest projection: env merged last (after broker
|
|
443
|
+
* placeholders), files written before the session starts, services run in
|
|
444
|
+
* the guest for the session's lifetime. Not subject to the profile env
|
|
445
|
+
* reserved-name guard — the runtime, not the profile, declares it.
|
|
446
|
+
*/
|
|
447
|
+
guestProjection?: GuestProjectionInput;
|
|
386
448
|
/** Structured credential-boundary diagnostics for daemon loggers. */
|
|
387
449
|
onDiagnostic?: (diagnostic: VmDiagnostic) => void;
|
|
388
450
|
/** Abort resume/setup work, closing any live VM owned by resumeVm. */
|
|
389
451
|
signal?: AbortSignal;
|
|
390
|
-
/**
|
|
391
|
-
* Coding-agent provider authentication to place in the guest (for example
|
|
392
|
-
* Pi's `auth.json`). The sandbox knows nothing about the provider: the
|
|
393
|
-
* runtime above it supplies the loader and the guest path. When omitted,
|
|
394
|
-
* nothing is written and the guest relies on forwarded env providers.
|
|
395
|
-
*/
|
|
396
|
-
providerAuth?: ProviderAuthSource;
|
|
397
452
|
}
|
|
398
453
|
|
|
399
454
|
export declare interface VmCredentials {
|
|
400
|
-
/** Empty in host-authenticated mode; retained as strings for API stability. */
|
|
401
|
-
moltnetJson: string;
|
|
402
|
-
/** Empty in host-authenticated mode; retained as strings for API stability. */
|
|
403
|
-
agentEnvRaw: string;
|
|
404
455
|
/**
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
* and the forwarded host environment instead. CI uses this path.
|
|
456
|
+
* Guest env carried across the boundary. Always empty: the guest receives no
|
|
457
|
+
* MoltNet credential material. Retained for API stability and because
|
|
458
|
+
* consumers read (empty) provider/diary/team hints from it.
|
|
409
459
|
*/
|
|
410
|
-
providerAuthJson: string | null;
|
|
411
460
|
agentEnv: Record<string, string | undefined>;
|
|
412
|
-
gitconfig: string | null;
|
|
413
|
-
sshPrivateKey: string | null;
|
|
414
|
-
sshPublicKey: string | null;
|
|
415
|
-
allowedSigners: string | null;
|
|
416
|
-
/** Raw PEM content of the GitHub App private key, or null if not configured. */
|
|
417
|
-
githubAppPem: string | null;
|
|
418
|
-
/** VM-local filename for the GitHub App PEM (basename of host path), or null. */
|
|
419
|
-
githubAppPemFilename: string | null;
|
|
420
461
|
}
|
|
421
462
|
|
|
422
463
|
export declare interface VmDiagnostic {
|
|
423
|
-
event: 'vm.credentials.mode' | 'vm.
|
|
464
|
+
event: 'vm.credentials.mode' | 'vm.http_secrets.bound' | 'vm.host_origins.bound' | 'vm.guest_projection.applied' | 'vm.guest_service.not_ready' | 'vm.network.origin_denied';
|
|
424
465
|
level: 'info' | 'warning';
|
|
425
466
|
message: string;
|
|
426
|
-
credentialMode: GuestCredentialMode;
|
|
427
467
|
/** Present only for the value-free broker summary event. */
|
|
428
468
|
brokeredSecretCount?: number;
|
|
469
|
+
/** Present only for the host-origins summary event. */
|
|
470
|
+
hostOriginCount?: number;
|
|
471
|
+
/** Present only for the guest-projection summary event. */
|
|
472
|
+
projectedFileCount?: number;
|
|
473
|
+
projectedServiceCount?: number;
|
|
474
|
+
/** Present only for an exact-origin denial event. */
|
|
475
|
+
origin?: {
|
|
476
|
+
hostname: string;
|
|
477
|
+
protocol: string;
|
|
478
|
+
port: number;
|
|
479
|
+
phase: 'request' | 'ip';
|
|
480
|
+
};
|
|
429
481
|
}
|
|
430
482
|
|
|
431
483
|
export { }
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import path, { isAbsolute, relative } from "node:path";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { existsSync, mkdirSync,
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
5
5
|
import { MemoryProvider, RealFSProvider, ShadowProvider, VM, VmCheckpoint, createHttpHooks, createShadowPathPredicate, ensureImageSelector, isWriteFlag, loadGuestAssets } from "@earendil-works/gondolin";
|
|
6
|
-
import {
|
|
6
|
+
import { isIP } from "node:net";
|
|
7
7
|
//#region src/abort-utils.ts
|
|
8
8
|
function throwIfAborted(signal, label) {
|
|
9
9
|
if (!signal?.aborted) return;
|
|
@@ -61,6 +61,138 @@ async function delay(ms, signal, label) {
|
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
63
|
//#endregion
|
|
64
|
+
//#region src/managed-exec.ts
|
|
65
|
+
function chunkBuffer(data) {
|
|
66
|
+
if (Buffer.isBuffer(data)) return data;
|
|
67
|
+
return typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
|
|
68
|
+
}
|
|
69
|
+
async function retireBackend(vm, trigger, onDiagnostic) {
|
|
70
|
+
onDiagnostic?.({
|
|
71
|
+
event: "managed_exec.backend_retirement",
|
|
72
|
+
trigger,
|
|
73
|
+
outcome: "started",
|
|
74
|
+
message: "retiring Gondolin VM after interrupted guest execution"
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
await vm.close();
|
|
78
|
+
onDiagnostic?.({
|
|
79
|
+
event: "managed_exec.backend_retirement",
|
|
80
|
+
trigger,
|
|
81
|
+
outcome: "succeeded",
|
|
82
|
+
message: "Gondolin VM retirement completed"
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
status: "backend-retired",
|
|
86
|
+
mode: "vm-close"
|
|
87
|
+
};
|
|
88
|
+
} catch (error) {
|
|
89
|
+
onDiagnostic?.({
|
|
90
|
+
event: "managed_exec.backend_retirement",
|
|
91
|
+
trigger,
|
|
92
|
+
outcome: "failed",
|
|
93
|
+
message: error instanceof Error ? `Gondolin VM retirement failed: ${error.message}` : "Gondolin VM retirement failed"
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
status: "recovery-required",
|
|
97
|
+
reason: "backend-retirement-failed"
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Execute one login-shell command in a Gondolin VM. Successful commands may
|
|
103
|
+
* reuse the VM. Timeout or cancellation retires the complete VM through the
|
|
104
|
+
* host API before returning, because no guest process/session boundary can
|
|
105
|
+
* prove containment against daemonization or a guest-mutated root filesystem.
|
|
106
|
+
*/
|
|
107
|
+
async function execManagedCommand(vm, command, options = {}) {
|
|
108
|
+
if (options.signal?.aborted) return {
|
|
109
|
+
exitCode: 130,
|
|
110
|
+
timedOut: false,
|
|
111
|
+
cancelled: true,
|
|
112
|
+
termination: { status: "not-started" }
|
|
113
|
+
};
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
let timedOut = false;
|
|
116
|
+
let cancelled = false;
|
|
117
|
+
let executionSettled = false;
|
|
118
|
+
let acceptOutput = true;
|
|
119
|
+
let resolveInterrupted;
|
|
120
|
+
let retirementPromise;
|
|
121
|
+
const interrupted = new Promise((resolve) => {
|
|
122
|
+
resolveInterrupted = resolve;
|
|
123
|
+
});
|
|
124
|
+
const interrupt = (trigger) => {
|
|
125
|
+
if (executionSettled || retirementPromise) return;
|
|
126
|
+
acceptOutput = false;
|
|
127
|
+
controller.abort(/* @__PURE__ */ new Error(`guest command interrupted by ${trigger}`));
|
|
128
|
+
retirementPromise = retireBackend(vm, trigger, options.onDiagnostic);
|
|
129
|
+
resolveInterrupted?.();
|
|
130
|
+
};
|
|
131
|
+
const onAbort = () => {
|
|
132
|
+
cancelled = true;
|
|
133
|
+
interrupt("cancellation");
|
|
134
|
+
};
|
|
135
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
136
|
+
const timer = options.timeoutMs !== void 0 && options.timeoutMs > 0 ? setTimeout(() => {
|
|
137
|
+
timedOut = true;
|
|
138
|
+
interrupt("timeout");
|
|
139
|
+
}, options.timeoutMs) : void 0;
|
|
140
|
+
timer?.unref();
|
|
141
|
+
try {
|
|
142
|
+
const process = vm.exec([
|
|
143
|
+
"/bin/sh",
|
|
144
|
+
"-lc",
|
|
145
|
+
command
|
|
146
|
+
], {
|
|
147
|
+
cwd: options.cwd,
|
|
148
|
+
env: options.env,
|
|
149
|
+
signal: controller.signal,
|
|
150
|
+
stdout: "pipe",
|
|
151
|
+
stderr: "pipe"
|
|
152
|
+
});
|
|
153
|
+
options.onStarted?.();
|
|
154
|
+
const execution = (async () => {
|
|
155
|
+
for await (const chunk of process.output()) {
|
|
156
|
+
if (!acceptOutput) continue;
|
|
157
|
+
options.onData?.(chunkBuffer(chunk.data), chunk.stream);
|
|
158
|
+
}
|
|
159
|
+
return process;
|
|
160
|
+
})();
|
|
161
|
+
const observedExecution = execution.then((result) => {
|
|
162
|
+
executionSettled = true;
|
|
163
|
+
return {
|
|
164
|
+
kind: "result",
|
|
165
|
+
result
|
|
166
|
+
};
|
|
167
|
+
}, (error) => {
|
|
168
|
+
executionSettled = true;
|
|
169
|
+
return {
|
|
170
|
+
kind: "error",
|
|
171
|
+
error
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
const first = await Promise.race([observedExecution, interrupted.then(() => ({ kind: "interrupted" }))]);
|
|
175
|
+
if (first.kind === "result" && !timedOut && !cancelled) return {
|
|
176
|
+
exitCode: first.result.exitCode,
|
|
177
|
+
timedOut: false,
|
|
178
|
+
cancelled: false,
|
|
179
|
+
termination: { status: "not-required" }
|
|
180
|
+
};
|
|
181
|
+
if (first.kind === "error" && !timedOut && !cancelled) throw first.error;
|
|
182
|
+
execution.catch(() => void 0);
|
|
183
|
+
const termination = await (retirementPromise ??= retireBackend(vm, timedOut ? "timeout" : "cancellation", options.onDiagnostic));
|
|
184
|
+
return {
|
|
185
|
+
exitCode: timedOut ? 124 : 130,
|
|
186
|
+
timedOut,
|
|
187
|
+
cancelled: cancelled && !timedOut,
|
|
188
|
+
termination
|
|
189
|
+
};
|
|
190
|
+
} finally {
|
|
191
|
+
if (timer) clearTimeout(timer);
|
|
192
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
//#endregion
|
|
64
196
|
//#region src/path-containment.ts
|
|
65
197
|
/**
|
|
66
198
|
* Check containment for already-resolved lexical or real paths.
|
|
@@ -100,6 +232,7 @@ var BASE_ALPINE_PACKAGE_EXECUTABLES = {
|
|
|
100
232
|
file: "file",
|
|
101
233
|
git: "git",
|
|
102
234
|
jq: "jq",
|
|
235
|
+
"openssh-keygen": "ssh-keygen",
|
|
103
236
|
ripgrep: "rg",
|
|
104
237
|
tar: "tar",
|
|
105
238
|
xz: "xz"
|
|
@@ -292,6 +425,35 @@ function pruneOldSnapshots(maxCached, currentDir) {
|
|
|
292
425
|
});
|
|
293
426
|
}
|
|
294
427
|
//#endregion
|
|
428
|
+
//#region src/host-origins.ts
|
|
429
|
+
function hostOriginHostnames(origins) {
|
|
430
|
+
return Object.keys(origins).map((origin) => new URL(origin).hostname).sort();
|
|
431
|
+
}
|
|
432
|
+
function blocked() {
|
|
433
|
+
return new Response(JSON.stringify({
|
|
434
|
+
code: "host_origin_blocked",
|
|
435
|
+
message: "origin not served"
|
|
436
|
+
}), {
|
|
437
|
+
status: 421,
|
|
438
|
+
headers: { "content-type": "application/json" }
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Serve a registered origin in-process. Fails closed for any request whose
|
|
443
|
+
* hostname belongs to a virtual origin but whose exact origin (scheme, host,
|
|
444
|
+
* port) is not registered: such a request must never fall through to normal
|
|
445
|
+
* outbound proxy forwarding (an SSRF route to a host-controlled name).
|
|
446
|
+
*/
|
|
447
|
+
function createHostOriginsOnRequest(origins) {
|
|
448
|
+
const virtualHostnames = new Set(hostOriginHostnames(origins));
|
|
449
|
+
return async (request) => {
|
|
450
|
+
const url = new URL(request.url);
|
|
451
|
+
const handler = origins[url.origin];
|
|
452
|
+
if (handler) return handler(request);
|
|
453
|
+
if (virtualHostnames.has(url.hostname)) return blocked();
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
//#endregion
|
|
295
457
|
//#region src/vm-manager.ts
|
|
296
458
|
/**
|
|
297
459
|
* Memory-backed VFS mount used by the daemon to inject task context
|
|
@@ -314,6 +476,12 @@ function pruneOldSnapshots(maxCached, currentDir) {
|
|
|
314
476
|
* investigation and the alternatives we rejected.
|
|
315
477
|
*/
|
|
316
478
|
var GUEST_TASK_CONTEXT_MOUNT = "/moltnet-task-context";
|
|
479
|
+
/** Guest directory holding one PID file per projected service. */
|
|
480
|
+
var GUEST_SERVICE_PID_DIR = "/run/moltnet/services";
|
|
481
|
+
var GUEST_SERVICE_ID_RE = /^[a-z][a-z0-9-]{0,62}$/;
|
|
482
|
+
function assertGuestServiceId(id) {
|
|
483
|
+
if (!GUEST_SERVICE_ID_RE.test(id)) throw new Error(`Invalid guest service id "${id}": expected ${GUEST_SERVICE_ID_RE}`);
|
|
484
|
+
}
|
|
317
485
|
/** @deprecated Use GUEST_TASK_CONTEXT_MOUNT. */
|
|
318
486
|
var GUEST_TASK_SKILLS_MOUNT = GUEST_TASK_CONTEXT_MOUNT;
|
|
319
487
|
function resolveVfsShadowConfig(config) {
|
|
@@ -396,59 +564,8 @@ function resolveVmAgentDir(config) {
|
|
|
396
564
|
const rootDir = config.agentRootDir ?? findMainWorktree();
|
|
397
565
|
return path.join(rootDir, ".moltnet", config.agentName);
|
|
398
566
|
}
|
|
399
|
-
function loadCredentials(
|
|
400
|
-
|
|
401
|
-
const agentEnvPath = path.join(agentDir, "env");
|
|
402
|
-
const providerAuthJson = providerAuth?.load() ?? null;
|
|
403
|
-
if (mode === "host-authenticated") return {
|
|
404
|
-
moltnetJson: "",
|
|
405
|
-
agentEnvRaw: "",
|
|
406
|
-
providerAuthJson,
|
|
407
|
-
agentEnv: {},
|
|
408
|
-
gitconfig: null,
|
|
409
|
-
sshPrivateKey: null,
|
|
410
|
-
sshPublicKey: null,
|
|
411
|
-
allowedSigners: null,
|
|
412
|
-
githubAppPem: null,
|
|
413
|
-
githubAppPemFilename: null
|
|
414
|
-
};
|
|
415
|
-
const hasMoltnetJson = existsSync(moltnetPath);
|
|
416
|
-
const hasAgentEnv = existsSync(agentEnvPath);
|
|
417
|
-
if (!hasMoltnetJson || !hasAgentEnv) throw new Error(`Guest credential mode requires both ${moltnetPath} and ${agentEnvPath}`);
|
|
418
|
-
const moltnetJson = readFileSync(moltnetPath, "utf8");
|
|
419
|
-
const agentEnvRaw = readFileSync(agentEnvPath, "utf8");
|
|
420
|
-
if (moltnetJson.trim() === "") throw new Error(`Agent configuration is empty: ${moltnetPath}`);
|
|
421
|
-
const gitconfigPath = path.join(agentDir, "gitconfig");
|
|
422
|
-
const gitconfig = existsSync(gitconfigPath) ? readFileSync(gitconfigPath, "utf8") : null;
|
|
423
|
-
const sshDir = path.join(agentDir, "ssh");
|
|
424
|
-
const sshPrivateKey = existsSync(path.join(sshDir, "id_ed25519")) ? readFileSync(path.join(sshDir, "id_ed25519"), "utf8") : null;
|
|
425
|
-
const sshPublicKey = existsSync(path.join(sshDir, "id_ed25519.pub")) ? readFileSync(path.join(sshDir, "id_ed25519.pub"), "utf8") : null;
|
|
426
|
-
const allowedSigners = existsSync(path.join(sshDir, "allowed_signers")) ? readFileSync(path.join(sshDir, "allowed_signers"), "utf8") : null;
|
|
427
|
-
let githubAppPem = null;
|
|
428
|
-
let githubAppPemFilename = null;
|
|
429
|
-
const pemPath = (moltnetJson ? JSON.parse(moltnetJson) : null)?.github?.private_key_path;
|
|
430
|
-
if (pemPath) if (!existsSync(pemPath)) onDiagnostic?.({
|
|
431
|
-
event: "vm.credentials.github_key_missing",
|
|
432
|
-
level: "warning",
|
|
433
|
-
credentialMode: mode,
|
|
434
|
-
message: `github.private_key_path not found at ${pemPath}; moltnet github token will fail inside the guest`
|
|
435
|
-
});
|
|
436
|
-
else {
|
|
437
|
-
githubAppPem = readFileSync(pemPath, "utf8");
|
|
438
|
-
githubAppPemFilename = path.basename(pemPath);
|
|
439
|
-
}
|
|
440
|
-
return {
|
|
441
|
-
moltnetJson,
|
|
442
|
-
agentEnvRaw,
|
|
443
|
-
providerAuthJson,
|
|
444
|
-
agentEnv: parseEnv(agentEnvRaw),
|
|
445
|
-
gitconfig,
|
|
446
|
-
sshPrivateKey,
|
|
447
|
-
sshPublicKey,
|
|
448
|
-
allowedSigners,
|
|
449
|
-
githubAppPem,
|
|
450
|
-
githubAppPemFilename
|
|
451
|
-
};
|
|
567
|
+
function loadCredentials() {
|
|
568
|
+
return { agentEnv: {} };
|
|
452
569
|
}
|
|
453
570
|
/**
|
|
454
571
|
* Apply agent env vars to the host process, mirroring `moltnet start`.
|
|
@@ -544,9 +661,13 @@ var OBJECT_META_PROPERTY_NAMES = new Set([
|
|
|
544
661
|
"prototype"
|
|
545
662
|
]);
|
|
546
663
|
function normalizeHostPattern(pattern) {
|
|
547
|
-
|
|
664
|
+
let normalized = pattern.trim().toLowerCase();
|
|
665
|
+
if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
|
|
666
|
+
if (normalized.endsWith(".")) normalized = normalized.slice(0, -1);
|
|
667
|
+
return normalized;
|
|
548
668
|
}
|
|
549
669
|
function isValidHostPattern(pattern) {
|
|
670
|
+
if (isIP(pattern) !== 0) return true;
|
|
550
671
|
return pattern !== "" && !pattern.includes("://") && !pattern.includes("/") && !pattern.includes(":") && !/\s/.test(pattern);
|
|
551
672
|
}
|
|
552
673
|
/**
|
|
@@ -581,8 +702,8 @@ function canonicalizeBrokeredHttpSecretDescriptor(descriptor) {
|
|
|
581
702
|
});
|
|
582
703
|
}
|
|
583
704
|
function hostMatchesPattern(hostname, pattern) {
|
|
584
|
-
const expression = pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
585
|
-
return new RegExp(`^${expression}$`, "i").test(hostname);
|
|
705
|
+
const expression = normalizeHostPattern(pattern).split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
706
|
+
return new RegExp(`^${expression}$`, "i").test(normalizeHostPattern(hostname));
|
|
586
707
|
}
|
|
587
708
|
/**
|
|
588
709
|
* Conservative subset check for Gondolin hostname globs. Exact secret hosts
|
|
@@ -654,6 +775,19 @@ function headersContainSecretValue(headers, value) {
|
|
|
654
775
|
}
|
|
655
776
|
return false;
|
|
656
777
|
}
|
|
778
|
+
function parseRequestOrigin(url) {
|
|
779
|
+
try {
|
|
780
|
+
const parsed = new URL(url);
|
|
781
|
+
const protocol = parsed.protocol.slice(0, -1);
|
|
782
|
+
return {
|
|
783
|
+
hostname: normalizeHostPattern(parsed.hostname),
|
|
784
|
+
protocol,
|
|
785
|
+
port: parsed.port ? Number(parsed.port) : protocol === "https" ? 443 : 80
|
|
786
|
+
};
|
|
787
|
+
} catch {
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
657
791
|
function createBrokeredHttpSecretOriginPolicy(bindings) {
|
|
658
792
|
const entries = /* @__PURE__ */ new Map();
|
|
659
793
|
for (const binding of bindings) {
|
|
@@ -670,17 +804,11 @@ function createBrokeredHttpSecretOriginPolicy(bindings) {
|
|
|
670
804
|
}
|
|
671
805
|
return {
|
|
672
806
|
isRequestAllowed(request) {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
url = new URL(request.url);
|
|
676
|
-
} catch {
|
|
677
|
-
return false;
|
|
678
|
-
}
|
|
679
|
-
const protocol = url.protocol.slice(0, -1);
|
|
680
|
-
const port = url.port ? Number(url.port) : protocol === "https" ? 443 : 80;
|
|
807
|
+
const requestOrigin = parseRequestOrigin(request.url);
|
|
808
|
+
if (!requestOrigin) return false;
|
|
681
809
|
for (const entry of entries.values()) {
|
|
682
810
|
if (entry.deleted || !headersContainSecretValue(request.headers, entry.value)) continue;
|
|
683
|
-
if (protocol !== entry.protocol || !entry.ports.includes(port) || !entry.hosts.some((host) => hostMatchesPattern(
|
|
811
|
+
if (requestOrigin.protocol !== entry.protocol || !entry.ports.includes(requestOrigin.port) || !entry.hosts.some((host) => hostMatchesPattern(requestOrigin.hostname, host))) return false;
|
|
684
812
|
}
|
|
685
813
|
return true;
|
|
686
814
|
},
|
|
@@ -697,18 +825,51 @@ function createBrokeredHttpSecretOriginPolicy(bindings) {
|
|
|
697
825
|
}
|
|
698
826
|
};
|
|
699
827
|
}
|
|
828
|
+
/**
|
|
829
|
+
* Refine Gondolin's hostname allowlist for hosts carrying brokered
|
|
830
|
+
* credentials. The built-in allowlist is hostname-granular; these callbacks
|
|
831
|
+
* enforce the descriptor protocol and port before request or IP dispatch.
|
|
832
|
+
*/
|
|
833
|
+
function createBrokeredHttpNetworkOriginPolicy(bindings, options = {}) {
|
|
834
|
+
const origins = bindings.map(canonicalizeBrokeredHttpSecretDescriptor);
|
|
835
|
+
const isAllowed = (input) => {
|
|
836
|
+
const hostname = normalizeHostPattern(input.hostname);
|
|
837
|
+
const matching = origins.filter((origin) => origin.hosts.some((host) => hostMatchesPattern(hostname, host)));
|
|
838
|
+
if (matching.length === 0) return true;
|
|
839
|
+
const allowed = matching.some((origin) => origin.protocol === input.protocol && origin.ports.includes(input.port));
|
|
840
|
+
if (!allowed) options.onDenied?.({
|
|
841
|
+
hostname,
|
|
842
|
+
protocol: input.protocol,
|
|
843
|
+
port: input.port,
|
|
844
|
+
phase: input.phase
|
|
845
|
+
});
|
|
846
|
+
return allowed;
|
|
847
|
+
};
|
|
848
|
+
return {
|
|
849
|
+
isRequestAllowed(request) {
|
|
850
|
+
const requestOrigin = parseRequestOrigin(request.url);
|
|
851
|
+
return requestOrigin ? isAllowed({
|
|
852
|
+
...requestOrigin,
|
|
853
|
+
phase: "request"
|
|
854
|
+
}) : false;
|
|
855
|
+
},
|
|
856
|
+
isIpAllowed(info) {
|
|
857
|
+
return isAllowed({
|
|
858
|
+
...info,
|
|
859
|
+
phase: "ip"
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
}
|
|
700
864
|
function assertGuestEnvironmentBoundary(options) {
|
|
701
|
-
const refusedForwardEnv = (options.forwardEnv ?? []).filter((name) => isReservedGuestEnvironmentName(name) ||
|
|
865
|
+
const refusedForwardEnv = (options.forwardEnv ?? []).filter((name) => isReservedGuestEnvironmentName(name) || !HOST_AUTHENTICATED_GUEST_ENV_ALLOWLIST.has(name));
|
|
702
866
|
const refusedSandboxEnv = Object.keys(options.sandboxEnv ?? {}).filter(isReservedGuestEnvironmentName);
|
|
703
867
|
const refused = [...new Set([...refusedForwardEnv, ...refusedSandboxEnv])].sort();
|
|
704
868
|
if (refused.length > 0) throw new GuestEnvironmentBoundaryError(refused);
|
|
705
869
|
}
|
|
706
|
-
/** @deprecated
|
|
870
|
+
/** @deprecated Alias of assertGuestEnvironmentBoundary (a single boundary). */
|
|
707
871
|
function assertHostAuthenticatedGuestEnvironment(options) {
|
|
708
|
-
assertGuestEnvironmentBoundary(
|
|
709
|
-
guestCredentialMode: "host-authenticated",
|
|
710
|
-
...options
|
|
711
|
-
});
|
|
872
|
+
assertGuestEnvironmentBoundary(options);
|
|
712
873
|
}
|
|
713
874
|
/**
|
|
714
875
|
* Return whether two Gondolin hostname globs can match at least one common
|
|
@@ -752,6 +913,10 @@ function assertInternalHostsDoNotOverlapProtectedHosts(internalHosts, protectedH
|
|
|
752
913
|
* surface immediately rather than fall through to cryptic agent
|
|
753
914
|
* errors later.
|
|
754
915
|
*/
|
|
916
|
+
/** Single-quote a POSIX shell argument (paths/ids are pre-validated). */
|
|
917
|
+
function shellQuote(value) {
|
|
918
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
919
|
+
}
|
|
755
920
|
async function vmRun(vm, label, command, signal) {
|
|
756
921
|
const wrapped = `set -eu\nset -o pipefail\n${command}`;
|
|
757
922
|
throwIfAborted(signal, `resume step "${label}"`);
|
|
@@ -781,22 +946,17 @@ async function resumeVm(config) {
|
|
|
781
946
|
throwIfAborted(config.signal, "VM resume");
|
|
782
947
|
const agentDir = resolveVmAgentDir(config);
|
|
783
948
|
const guestWorkspace = path.resolve(config.mountPath);
|
|
784
|
-
const guestCredentialMode = config.guestCredentialMode ?? "guest-config";
|
|
785
|
-
if (guestCredentialMode === "guest-config" && !existsSync(agentDir)) throw new Error(`Agent directory not found: ${agentDir}. Run: moltnet register --name ${config.agentName}`);
|
|
786
949
|
assertGuestEnvironmentBoundary({
|
|
787
|
-
guestCredentialMode,
|
|
788
950
|
forwardEnv: config.forwardEnv,
|
|
789
951
|
sandboxEnv: config.sandboxConfig?.env
|
|
790
952
|
});
|
|
791
953
|
config.onDiagnostic?.({
|
|
792
954
|
event: "vm.credentials.mode",
|
|
793
955
|
level: "info",
|
|
794
|
-
|
|
795
|
-
message: guestCredentialMode === "host-authenticated" ? "MoltNet agent files and non-allowlisted host environment variables are withheld from the guest" : "The complete MoltNet agent configuration is available to the guest"
|
|
956
|
+
message: "MoltNet agent files and non-allowlisted host environment variables are withheld from the guest"
|
|
796
957
|
});
|
|
797
|
-
const creds = loadCredentials(
|
|
798
|
-
const
|
|
799
|
-
const apiHost = new URL(configuredApiUrl ?? process.env.MOLTNET_API_URL ?? DEFAULT_MOLTNET_API_URL).hostname;
|
|
958
|
+
const creds = loadCredentials();
|
|
959
|
+
const apiHost = new URL(process.env.MOLTNET_API_URL ?? DEFAULT_MOLTNET_API_URL).hostname;
|
|
800
960
|
const runtimeAllowedHosts = config.sandboxConfig?.network?.allowedHosts ?? [];
|
|
801
961
|
const runtimeAllowedInternalHosts = config.sandboxConfig?.network?.allowedInternalHosts ?? [];
|
|
802
962
|
const protectedExternalHosts = [...new Set([
|
|
@@ -806,9 +966,14 @@ async function resumeVm(config) {
|
|
|
806
966
|
])];
|
|
807
967
|
assertInternalHostsDoNotOverlapProtectedHosts(runtimeAllowedInternalHosts, protectedExternalHosts);
|
|
808
968
|
const allowedHosts = [...new Set([...protectedExternalHosts, ...runtimeAllowedHosts])];
|
|
969
|
+
const hostOrigins = config.hostOrigins ?? {};
|
|
970
|
+
const hostOriginHosts = hostOriginHostnames(hostOrigins);
|
|
971
|
+
assertInternalHostsDoNotOverlapProtectedHosts(hostOriginHosts, protectedExternalHosts);
|
|
972
|
+
const allowedInternalHosts = [...new Set([...runtimeAllowedInternalHosts, ...hostOriginHosts])];
|
|
973
|
+
const projectedEnv = config.guestProjection?.env ?? {};
|
|
809
974
|
const brokeredSecrets = prepareBrokeredHttpSecrets({
|
|
810
975
|
bindings: config.brokeredSecrets,
|
|
811
|
-
allowedHosts: [...allowedHosts, ...
|
|
976
|
+
allowedHosts: [...allowedHosts, ...allowedInternalHosts],
|
|
812
977
|
occupiedGuestEnvNames: [
|
|
813
978
|
"PATH",
|
|
814
979
|
"HOME",
|
|
@@ -817,17 +982,28 @@ async function resumeVm(config) {
|
|
|
817
982
|
"MOLTNET_GUEST_WORKSPACE",
|
|
818
983
|
...config.forwardEnv ?? [],
|
|
819
984
|
...Object.keys(creds.agentEnv),
|
|
820
|
-
...Object.keys(config.sandboxConfig?.env ?? {})
|
|
985
|
+
...Object.keys(config.sandboxConfig?.env ?? {}),
|
|
986
|
+
...Object.keys(projectedEnv)
|
|
821
987
|
]
|
|
822
988
|
});
|
|
823
989
|
const brokeredSecretOriginPolicy = createBrokeredHttpSecretOriginPolicy(config.brokeredSecrets ?? []);
|
|
990
|
+
const brokeredNetworkOriginPolicy = createBrokeredHttpNetworkOriginPolicy(config.brokeredSecrets ?? [], { onDenied(origin) {
|
|
991
|
+
config.onDiagnostic?.({
|
|
992
|
+
event: "vm.network.origin_denied",
|
|
993
|
+
level: "warning",
|
|
994
|
+
message: "denied request outside a brokered credential origin",
|
|
995
|
+
origin
|
|
996
|
+
});
|
|
997
|
+
} });
|
|
824
998
|
const { httpHooks, env: secretEnv, secretManager: gondolinSecretManager } = createHttpHooks({
|
|
825
999
|
allowedHosts,
|
|
826
|
-
allowedInternalHosts
|
|
1000
|
+
allowedInternalHosts,
|
|
827
1001
|
...Object.keys(brokeredSecrets).length > 0 && {
|
|
828
1002
|
secrets: brokeredSecrets,
|
|
829
|
-
isRequestAllowed: brokeredSecretOriginPolicy.isRequestAllowed
|
|
830
|
-
|
|
1003
|
+
isRequestAllowed: (request) => brokeredNetworkOriginPolicy.isRequestAllowed(request) && brokeredSecretOriginPolicy.isRequestAllowed(request),
|
|
1004
|
+
isIpAllowed: brokeredNetworkOriginPolicy.isIpAllowed
|
|
1005
|
+
},
|
|
1006
|
+
...hostOriginHosts.length > 0 && { onRequest: createHostOriginsOnRequest(hostOrigins) }
|
|
831
1007
|
});
|
|
832
1008
|
const secretManager = Object.freeze({
|
|
833
1009
|
rotateSecret(guestEnv, value) {
|
|
@@ -843,19 +1019,15 @@ async function resumeVm(config) {
|
|
|
843
1019
|
if (brokeredSecretCount > 0) config.onDiagnostic?.({
|
|
844
1020
|
event: "vm.http_secrets.bound",
|
|
845
1021
|
level: "info",
|
|
846
|
-
credentialMode: guestCredentialMode,
|
|
847
1022
|
brokeredSecretCount,
|
|
848
1023
|
message: `Bound ${brokeredSecretCount} HTTP secret placeholder${brokeredSecretCount === 1 ? "" : "s"} to the host proxy`
|
|
849
1024
|
});
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
else vmAgentEnv[k] = v;
|
|
857
|
-
}
|
|
858
|
-
if (creds.moltnetJson) vmAgentEnv.MOLTNET_CREDENTIALS_PATH = `${vmAgentDir}/moltnet.json`;
|
|
1025
|
+
if (hostOriginHosts.length > 0) config.onDiagnostic?.({
|
|
1026
|
+
event: "vm.host_origins.bound",
|
|
1027
|
+
level: "info",
|
|
1028
|
+
hostOriginCount: hostOriginHosts.length,
|
|
1029
|
+
message: `Bound ${hostOriginHosts.length} host origin${hostOriginHosts.length === 1 ? "" : "s"} to the host proxy`
|
|
1030
|
+
});
|
|
859
1031
|
const vfsConfig = resolveVfsShadowConfig(config.sandboxConfig);
|
|
860
1032
|
let workspaceProvider = new RealFSProvider(config.mountPath);
|
|
861
1033
|
workspaceProvider = new ShadowProvider(workspaceProvider, {
|
|
@@ -871,7 +1043,7 @@ async function resumeVm(config) {
|
|
|
871
1043
|
writeMode: vfsConfig.mode
|
|
872
1044
|
});
|
|
873
1045
|
}
|
|
874
|
-
|
|
1046
|
+
workspaceProvider = new ShadowProvider(workspaceProvider, {
|
|
875
1047
|
shouldShadow: ({ path: shadowPath }) => shadowPath.split("/").includes(".moltnet"),
|
|
876
1048
|
denySymlinkBypass: true,
|
|
877
1049
|
writeMode: "deny"
|
|
@@ -884,7 +1056,6 @@ async function resumeVm(config) {
|
|
|
884
1056
|
}
|
|
885
1057
|
const envOverrides = config.sandboxConfig?.env ?? {};
|
|
886
1058
|
const vmEnv = {
|
|
887
|
-
...vmAgentEnv,
|
|
888
1059
|
...forwardedEnv,
|
|
889
1060
|
PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/go/bin",
|
|
890
1061
|
HOME: "/home/agent",
|
|
@@ -892,7 +1063,8 @@ async function resumeVm(config) {
|
|
|
892
1063
|
NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt",
|
|
893
1064
|
...envOverrides,
|
|
894
1065
|
MOLTNET_GUEST_WORKSPACE: guestWorkspace,
|
|
895
|
-
...secretEnv
|
|
1066
|
+
...secretEnv,
|
|
1067
|
+
...projectedEnv
|
|
896
1068
|
};
|
|
897
1069
|
const resources = config.sandboxConfig?.resources;
|
|
898
1070
|
const workspaceMode = config.workspaceMode ?? "shared_mount";
|
|
@@ -915,6 +1087,27 @@ async function resumeVm(config) {
|
|
|
915
1087
|
process.stderr.write(`[vm] aborted resume late vm.close() failed: ${message}\n`);
|
|
916
1088
|
}
|
|
917
1089
|
});
|
|
1090
|
+
const servicesAbort = new AbortController();
|
|
1091
|
+
const serviceHandles = [];
|
|
1092
|
+
const serviceIds = [];
|
|
1093
|
+
const services = { async stop() {
|
|
1094
|
+
if (serviceIds.length > 0) {
|
|
1095
|
+
const pidFiles = serviceIds.map((id) => `${GUEST_SERVICE_PID_DIR}/${id}.pid`).join(" ");
|
|
1096
|
+
try {
|
|
1097
|
+
await vm.exec([
|
|
1098
|
+
"sh",
|
|
1099
|
+
"-c",
|
|
1100
|
+
`for f in ${pidFiles}; do [ -f "$f" ] || continue; pid=$(cat "$f"); kill -TERM -- "-\$pid" 2>/dev/null || kill -TERM "\$pid" 2>/dev/null || true; done; sleep 0.2; for f in ${pidFiles}; do [ -f "$f" ] || continue; pid=$(cat "$f"); kill -KILL -- "-\$pid" 2>/dev/null || kill -KILL "\$pid" 2>/dev/null || true; rm -f "\$f"; done`
|
|
1101
|
+
], {
|
|
1102
|
+
stdout: "ignore",
|
|
1103
|
+
stderr: "ignore"
|
|
1104
|
+
});
|
|
1105
|
+
} catch {}
|
|
1106
|
+
serviceIds.length = 0;
|
|
1107
|
+
}
|
|
1108
|
+
servicesAbort.abort();
|
|
1109
|
+
await Promise.allSettled(serviceHandles);
|
|
1110
|
+
} };
|
|
918
1111
|
try {
|
|
919
1112
|
await vmRun(vm, "TLS certificates", `
|
|
920
1113
|
cp /etc/gondolin/mitm/ca.crt /usr/local/share/ca-certificates/gondolin-mitm.crt
|
|
@@ -947,59 +1140,92 @@ async function resumeVm(config) {
|
|
|
947
1140
|
}
|
|
948
1141
|
if (lastErr) throw lastErr instanceof Error ? lastErr : new Error(nonErrorMessage(lastErr));
|
|
949
1142
|
}
|
|
950
|
-
const
|
|
951
|
-
const
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
signal: config.signal
|
|
958
|
-
});
|
|
959
|
-
if (hasAgentFiles) {
|
|
960
|
-
const vmMoltnetJson = rewriteMoltnetJsonPaths(creds.moltnetJson, vmAgentDir, vmSshDir, creds.githubAppPemFilename);
|
|
961
|
-
await vm.fs.writeFile(`${vmAgentDir}/moltnet.json`, vmMoltnetJson, {
|
|
962
|
-
mode: 384,
|
|
1143
|
+
const projectedFiles = config.guestProjection?.files ?? [];
|
|
1144
|
+
const projectedDirs = [...new Set(projectedFiles.map((file) => path.posix.dirname(file.path)))];
|
|
1145
|
+
const guestDirs = [...projectedDirs];
|
|
1146
|
+
if (guestDirs.length > 0) await vmRun(vm, "create guest directories", `mkdir -p ${guestDirs.map(shellQuote).join(" ")}`, config.signal);
|
|
1147
|
+
for (const file of projectedFiles) {
|
|
1148
|
+
await vm.fs.writeFile(file.path, file.content, {
|
|
1149
|
+
mode: file.mode ?? 420,
|
|
963
1150
|
signal: config.signal
|
|
964
1151
|
});
|
|
965
|
-
await vm.
|
|
966
|
-
|
|
967
|
-
|
|
1152
|
+
if (file.mode !== void 0) await vmRun(vm, `chmod projected file ${file.path}`, `chmod ${file.mode.toString(8)} ${shellQuote(file.path)}`, config.signal);
|
|
1153
|
+
}
|
|
1154
|
+
await vmRun(vm, "chown guest directories", `set -e; for d in ${projectedDirs.filter((dir) => dir.startsWith("/home/agent/")).map(shellQuote).join(" ")}; do if [ -e "$d" ]; then chown -R agent:agent "$d"; fi; done`, config.signal);
|
|
1155
|
+
const projectedServices = config.guestProjection?.services ?? [];
|
|
1156
|
+
if (projectedServices.length > 0) await vmRun(vm, "create service pid directory", `mkdir -p ${GUEST_SERVICE_PID_DIR}`, config.signal);
|
|
1157
|
+
for (const service of projectedServices) {
|
|
1158
|
+
assertGuestServiceId(service.id);
|
|
1159
|
+
const handle = vm.exec([
|
|
1160
|
+
"setsid",
|
|
1161
|
+
"sh",
|
|
1162
|
+
"-c",
|
|
1163
|
+
`echo $$ > ${GUEST_SERVICE_PID_DIR}/${service.id}.pid && exec "$@"`,
|
|
1164
|
+
"moltnet-service",
|
|
1165
|
+
...service.command
|
|
1166
|
+
], {
|
|
1167
|
+
stdout: "ignore",
|
|
1168
|
+
stderr: "ignore",
|
|
1169
|
+
...service.env && { env: service.env },
|
|
1170
|
+
signal: servicesAbort.signal
|
|
968
1171
|
});
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1172
|
+
serviceHandles.push(Promise.resolve(handle).catch(() => void 0));
|
|
1173
|
+
serviceIds.push(service.id);
|
|
1174
|
+
}
|
|
1175
|
+
async function awaitServiceReady(service) {
|
|
1176
|
+
if (!service.readiness) return;
|
|
1177
|
+
const deadline = Date.now() + (service.readiness.timeoutMs ?? 1e4);
|
|
1178
|
+
const pidFile = `${GUEST_SERVICE_PID_DIR}/${service.id}.pid`;
|
|
1179
|
+
let ready = false;
|
|
1180
|
+
while (Date.now() < deadline) {
|
|
1181
|
+
throwIfAborted(config.signal, `service "${service.id}" readiness`);
|
|
1182
|
+
if ((await vm.exec([
|
|
1183
|
+
"sh",
|
|
1184
|
+
"-c",
|
|
1185
|
+
"[ -e \"$1\" ] && [ -r \"$2\" ] && kill -0 \"$(cat \"$2\" 2>/dev/null)\" 2>/dev/null",
|
|
1186
|
+
"moltnet-readiness",
|
|
1187
|
+
service.readiness.path,
|
|
1188
|
+
pidFile
|
|
1189
|
+
], {
|
|
1190
|
+
stdout: "ignore",
|
|
1191
|
+
stderr: "ignore",
|
|
973
1192
|
signal: config.signal
|
|
1193
|
+
})).exitCode === 0) {
|
|
1194
|
+
ready = true;
|
|
1195
|
+
break;
|
|
1196
|
+
}
|
|
1197
|
+
await new Promise((resolve) => {
|
|
1198
|
+
setTimeout(resolve, 200);
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
if (!ready) {
|
|
1202
|
+
if (service.readiness.required) throw new Error(`Projected guest service "${service.id}" did not become ready: ${service.readiness.path} absent or its process exited`);
|
|
1203
|
+
config.onDiagnostic?.({
|
|
1204
|
+
event: "vm.guest_service.not_ready",
|
|
1205
|
+
level: "warning",
|
|
1206
|
+
message: `Projected guest service "${service.id}" did not become ready (${service.readiness.path} absent or its process exited); continuing without it`
|
|
974
1207
|
});
|
|
975
1208
|
}
|
|
976
|
-
if (creds.sshPrivateKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519`, creds.sshPrivateKey, {
|
|
977
|
-
mode: 384,
|
|
978
|
-
signal: config.signal
|
|
979
|
-
});
|
|
980
|
-
if (creds.sshPublicKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519.pub`, creds.sshPublicKey, {
|
|
981
|
-
mode: 420,
|
|
982
|
-
signal: config.signal
|
|
983
|
-
});
|
|
984
|
-
if (creds.allowedSigners) await vm.fs.writeFile(`${vmSshDir}/allowed_signers`, creds.allowedSigners, {
|
|
985
|
-
mode: 420,
|
|
986
|
-
signal: config.signal
|
|
987
|
-
});
|
|
988
|
-
if (creds.githubAppPem && creds.githubAppPemFilename) await vm.fs.writeFile(`${vmAgentDir}/${creds.githubAppPemFilename}`, creds.githubAppPem, {
|
|
989
|
-
mode: 384,
|
|
990
|
-
signal: config.signal
|
|
991
|
-
});
|
|
992
1209
|
}
|
|
993
|
-
await
|
|
1210
|
+
await Promise.all(projectedServices.map((service) => awaitServiceReady(service)));
|
|
1211
|
+
if (projectedFiles.length > 0 || projectedServices.length > 0) config.onDiagnostic?.({
|
|
1212
|
+
event: "vm.guest_projection.applied",
|
|
1213
|
+
level: "info",
|
|
1214
|
+
projectedFileCount: projectedFiles.length,
|
|
1215
|
+
projectedServiceCount: projectedServices.length,
|
|
1216
|
+
message: `Applied guest projection: ${projectedFiles.length} file(s), ${projectedServices.length} service(s)`
|
|
1217
|
+
});
|
|
994
1218
|
return {
|
|
995
1219
|
vm,
|
|
996
1220
|
credentials: creds,
|
|
997
1221
|
secretManager,
|
|
1222
|
+
services,
|
|
998
1223
|
mountPath: config.mountPath,
|
|
999
1224
|
guestWorkspace,
|
|
1000
1225
|
agentDir
|
|
1001
1226
|
};
|
|
1002
1227
|
} catch (err) {
|
|
1228
|
+
servicesAbort.abort();
|
|
1003
1229
|
try {
|
|
1004
1230
|
await vm.close();
|
|
1005
1231
|
} catch (closeErr) {
|
|
@@ -1009,66 +1235,5 @@ async function resumeVm(config) {
|
|
|
1009
1235
|
throw err;
|
|
1010
1236
|
}
|
|
1011
1237
|
}
|
|
1012
|
-
/**
|
|
1013
|
-
* Rewrite host-absolute paths inside an agent gitconfig to VM-local
|
|
1014
|
-
* equivalents before injecting it into the guest.
|
|
1015
|
-
*
|
|
1016
|
-
* Two rewrites:
|
|
1017
|
-
* - `signingKey = <host path>` → `<vmSshDir>/id_ed25519`
|
|
1018
|
-
* - `... credential-helper --credentials <host moltnet.json>`
|
|
1019
|
-
* → `<vmAgentDir>/moltnet.json`
|
|
1020
|
-
*
|
|
1021
|
-
* The credential-helper line is generated host-side by `moltnet github setup`
|
|
1022
|
-
* with a host-absolute `--credentials` path; inside the guest that path is
|
|
1023
|
-
* invalid, so it must point at the VM-side moltnet.json. The `insteadOf`
|
|
1024
|
-
* rewrite rule and every other line are workspace-independent and pass through
|
|
1025
|
-
* unchanged. A gitconfig without a credential helper is rewritten only for
|
|
1026
|
-
* `signingKey`.
|
|
1027
|
-
*
|
|
1028
|
-
* This is the single source of truth for git push auth in the guest: the
|
|
1029
|
-
* injected gitconfig carries the tokenless mint-on-demand helper, so the VM
|
|
1030
|
-
* no longer hand-rolls a credential-helper script or runs an imperative
|
|
1031
|
-
* `git config --global ... insteadOf` against the guest $HOME.
|
|
1032
|
-
*/
|
|
1033
|
-
function rewriteGitconfigPaths(gitconfig, vmSshDir, vmAgentDir) {
|
|
1034
|
-
return gitconfig.replace(/signingKey\s*=\s*.+/g, `signingKey = ${vmSshDir}/id_ed25519`).replace(/(moltnet github credential-helper --credentials )\S+/g, `$1${vmAgentDir}/moltnet.json`);
|
|
1035
|
-
}
|
|
1036
|
-
/**
|
|
1037
|
-
* Rewrite host-absolute paths inside moltnet.json to VM-local equivalents.
|
|
1038
|
-
*
|
|
1039
|
-
* Fields rewritten:
|
|
1040
|
-
* ssh.private_key_path → <vmSshDir>/<basename of original>
|
|
1041
|
-
* ssh.public_key_path → <vmSshDir>/<basename of original>
|
|
1042
|
-
* git.config_path → <vmAgentDir>/gitconfig
|
|
1043
|
-
* github.private_key_path → <vmAgentDir>/<pemFilename> (if present)
|
|
1044
|
-
*
|
|
1045
|
-
* All other fields are passed through unchanged.
|
|
1046
|
-
* Throws if moltnetJson is not valid JSON — callers must not inject a broken
|
|
1047
|
-
* moltnet.json into the guest.
|
|
1048
|
-
*/
|
|
1049
|
-
function rewriteMoltnetJsonPaths(moltnetJson, vmAgentDir, vmSshDir, githubAppPemFilename) {
|
|
1050
|
-
const config = JSON.parse(moltnetJson);
|
|
1051
|
-
if (config.ssh && typeof config.ssh === "object") {
|
|
1052
|
-
const ssh = config.ssh;
|
|
1053
|
-
const origPrivate = typeof ssh.private_key_path === "string" ? ssh.private_key_path : null;
|
|
1054
|
-
const origPublic = typeof ssh.public_key_path === "string" ? ssh.public_key_path : null;
|
|
1055
|
-
config.ssh = {
|
|
1056
|
-
...ssh,
|
|
1057
|
-
...origPrivate !== null && { private_key_path: `${vmSshDir}/${path.basename(origPrivate)}` },
|
|
1058
|
-
...origPublic !== null && { public_key_path: `${vmSshDir}/${path.basename(origPublic)}` }
|
|
1059
|
-
};
|
|
1060
|
-
}
|
|
1061
|
-
if (config.git && typeof config.git === "object") {
|
|
1062
|
-
const git = { ...config.git };
|
|
1063
|
-
git.config_path = `${vmAgentDir}/gitconfig`;
|
|
1064
|
-
config.git = git;
|
|
1065
|
-
}
|
|
1066
|
-
if (githubAppPemFilename && config.github && typeof config.github === "object") {
|
|
1067
|
-
const github = { ...config.github };
|
|
1068
|
-
github.private_key_path = `${vmAgentDir}/${githubAppPemFilename}`;
|
|
1069
|
-
config.github = github;
|
|
1070
|
-
}
|
|
1071
|
-
return JSON.stringify(config);
|
|
1072
|
-
}
|
|
1073
1238
|
//#endregion
|
|
1074
|
-
export { AutoParentMemoryProvider, BrokeredHttpSecretBoundaryError, GONDOLIN_BASE_EXECUTABLES, GUEST_TASK_CONTEXT_MOUNT, GUEST_TASK_SKILLS_MOUNT, GuestEnvironmentBoundaryError, abortableResource, activateAgentEnv, assertGuestEnvironmentBoundary, assertHostAuthenticatedGuestEnvironment, canonicalizeBrokeredHttpSecretDescriptor, delay, ensureSnapshot, findMainWorktree, isResolvedPathInsideRoot, loadCredentials, prepareBrokeredHttpSecrets, resolveVfsShadowConfig, resolveVmAgentDir, resumeVm,
|
|
1239
|
+
export { AutoParentMemoryProvider, BrokeredHttpSecretBoundaryError, GONDOLIN_BASE_EXECUTABLES, GUEST_SERVICE_PID_DIR, GUEST_TASK_CONTEXT_MOUNT, GUEST_TASK_SKILLS_MOUNT, GuestEnvironmentBoundaryError, abortableResource, activateAgentEnv, assertGuestEnvironmentBoundary, assertHostAuthenticatedGuestEnvironment, canonicalizeBrokeredHttpSecretDescriptor, createBrokeredHttpNetworkOriginPolicy, delay, ensureSnapshot, execManagedCommand, findMainWorktree, isResolvedPathInsideRoot, loadCredentials, prepareBrokeredHttpSecrets, resolveVfsShadowConfig, resolveVmAgentDir, resumeVm, shouldRunResumeCommand, shouldShadowNodeModulesPath, throwIfAborted };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@themoltnet/sandbox-gondolin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Gondolin microVM sandbox lifecycle for MoltNet runtimes: checkpoint resume, egress policy, VFS shadowing, guest credential boundary, host-brokered secrets",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"moltnet",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
}
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@earendil-works/gondolin": "^0.
|
|
50
|
+
"@earendil-works/gondolin": "^0.12.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^22.19.0",
|