@themoltnet/sandbox-gondolin 0.3.0 → 0.5.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 +19 -9
- package/dist/index.d.ts +83 -80
- package/dist/index.js +312 -225
- 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'] },
|
|
@@ -89,6 +87,18 @@ queries. Gondolin also decodes, substitutes, and re-encodes HTTP Basic
|
|
|
89
87
|
authorization, covering the password encoding used by HTTPS Git credential
|
|
90
88
|
helpers; OAuth client secrets sent in form bodies remain host-only.
|
|
91
89
|
|
|
90
|
+
`vm.network.policy_bound` reports the complete hostname inputs passed to
|
|
91
|
+
Gondolin; it is configuration evidence, not independent proof of Gondolin's
|
|
92
|
+
resolved state. Brokered requests emit `vm.network.origin_checked` for
|
|
93
|
+
canonical protocol/hostname/port decisions at request and IP phases. Denials
|
|
94
|
+
remain available as `vm.network.origin_denied`. All diagnostics are
|
|
95
|
+
value-free.
|
|
96
|
+
|
|
97
|
+
Gondolin 0.12 pins the actual upstream connection to the policy-checked IP only
|
|
98
|
+
when using its default fetch. This package therefore does not expose a custom
|
|
99
|
+
fetch or fixture-routing seam: doing so would retain a preflight IP check while
|
|
100
|
+
losing connect-time pinning and reopening a DNS-rebinding window.
|
|
101
|
+
|
|
92
102
|
Rotation and revocation do not require exposing or changing the guest
|
|
93
103
|
placeholder:
|
|
94
104
|
|
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,21 @@ 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
|
+
onDecision?: (decision: RequestOrigin & {
|
|
95
|
+
phase: 'request' | 'ip';
|
|
96
|
+
allowed: boolean;
|
|
97
|
+
}) => void;
|
|
98
|
+
}): {
|
|
99
|
+
isRequestAllowed: (request: Request) => boolean;
|
|
100
|
+
isIpAllowed: (info: HttpIpAllowInfo) => boolean;
|
|
101
|
+
};
|
|
102
|
+
|
|
88
103
|
export declare function delay(ms: number, signal: AbortSignal | undefined, label: string): Promise<void>;
|
|
89
104
|
|
|
90
105
|
/**
|
|
@@ -100,6 +115,14 @@ export declare interface EnsureSnapshotOptions {
|
|
|
100
115
|
maxCached?: number;
|
|
101
116
|
}
|
|
102
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Execute one login-shell command in a Gondolin VM. Successful commands may
|
|
120
|
+
* reuse the VM. Timeout or cancellation retires the complete VM through the
|
|
121
|
+
* host API before returning, because no guest process/session boundary can
|
|
122
|
+
* prove containment against daemonization or a guest-mutated root filesystem.
|
|
123
|
+
*/
|
|
124
|
+
export declare function execManagedCommand(vm: VM, command: string, options?: ManagedExecOptions): Promise<ManagedExecResult>;
|
|
125
|
+
|
|
103
126
|
/**
|
|
104
127
|
* Resolve the main worktree root (where .moltnet/ lives — it's untracked,
|
|
105
128
|
* only exists in the main worktree, not in git worktrees).
|
|
@@ -137,8 +160,6 @@ export declare const GUEST_TASK_CONTEXT_MOUNT = "/moltnet-task-context";
|
|
|
137
160
|
/** @deprecated Use GUEST_TASK_CONTEXT_MOUNT. */
|
|
138
161
|
export declare const GUEST_TASK_SKILLS_MOUNT = "/moltnet-task-context";
|
|
139
162
|
|
|
140
|
-
export declare type GuestCredentialMode = 'guest-config' | 'host-authenticated';
|
|
141
|
-
|
|
142
163
|
export declare class GuestEnvironmentBoundaryError extends Error {
|
|
143
164
|
readonly refusedNames: readonly string[];
|
|
144
165
|
constructor(refusedNames: readonly string[]);
|
|
@@ -187,7 +208,43 @@ declare type HostOriginHandler = (request: Request) => Promise<Response>;
|
|
|
187
208
|
*/
|
|
188
209
|
export declare function isResolvedPathInsideRoot(path: string, root: string): boolean;
|
|
189
210
|
|
|
190
|
-
export declare function loadCredentials(
|
|
211
|
+
export declare function loadCredentials(): VmCredentials;
|
|
212
|
+
|
|
213
|
+
export declare interface ManagedExecDiagnostic {
|
|
214
|
+
event: 'managed_exec.backend_retirement';
|
|
215
|
+
trigger: 'cancellation' | 'timeout';
|
|
216
|
+
outcome: 'started' | 'succeeded' | 'failed';
|
|
217
|
+
message: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export declare interface ManagedExecOptions {
|
|
221
|
+
cwd?: string;
|
|
222
|
+
env?: Record<string, string>;
|
|
223
|
+
signal?: AbortSignal;
|
|
224
|
+
timeoutMs?: number;
|
|
225
|
+
onData?: (data: Buffer, stream: 'stdout' | 'stderr') => void;
|
|
226
|
+
onDiagnostic?: (diagnostic: ManagedExecDiagnostic) => void;
|
|
227
|
+
onStarted?: () => void;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export declare interface ManagedExecResult {
|
|
231
|
+
exitCode: number;
|
|
232
|
+
timedOut: boolean;
|
|
233
|
+
cancelled: boolean;
|
|
234
|
+
termination: ManagedExecTermination;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export declare type ManagedExecTermination = {
|
|
238
|
+
status: 'not-required';
|
|
239
|
+
} | {
|
|
240
|
+
status: 'not-started';
|
|
241
|
+
} | {
|
|
242
|
+
status: 'backend-retired';
|
|
243
|
+
mode: 'vm-close';
|
|
244
|
+
} | {
|
|
245
|
+
status: 'recovery-required';
|
|
246
|
+
reason: 'backend-retirement-failed';
|
|
247
|
+
};
|
|
191
248
|
|
|
192
249
|
export declare interface ManagedVm {
|
|
193
250
|
vm: VM;
|
|
@@ -211,11 +268,10 @@ export declare function prepareBrokeredHttpSecrets(options: {
|
|
|
211
268
|
occupiedGuestEnvNames?: readonly string[];
|
|
212
269
|
}): Record<string, SecretDefinition>;
|
|
213
270
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
guestPath: string;
|
|
271
|
+
declare interface RequestOrigin {
|
|
272
|
+
hostname: string;
|
|
273
|
+
protocol: string;
|
|
274
|
+
port: number;
|
|
219
275
|
}
|
|
220
276
|
|
|
221
277
|
export declare function resolveVfsShadowConfig(config: SandboxConfig | undefined): {
|
|
@@ -259,44 +315,6 @@ export declare interface ResumeCommandWhen {
|
|
|
259
315
|
*/
|
|
260
316
|
export declare function resumeVm(config: VmConfig): Promise<ManagedVm>;
|
|
261
317
|
|
|
262
|
-
/**
|
|
263
|
-
* Rewrite host-absolute paths inside an agent gitconfig to VM-local
|
|
264
|
-
* equivalents before injecting it into the guest.
|
|
265
|
-
*
|
|
266
|
-
* Two rewrites:
|
|
267
|
-
* - `signingKey = <host path>` → `<vmSshDir>/id_ed25519`
|
|
268
|
-
* - `... credential-helper --credentials <host moltnet.json>`
|
|
269
|
-
* → `<vmAgentDir>/moltnet.json`
|
|
270
|
-
*
|
|
271
|
-
* The credential-helper line is generated host-side by `moltnet github setup`
|
|
272
|
-
* with a host-absolute `--credentials` path; inside the guest that path is
|
|
273
|
-
* invalid, so it must point at the VM-side moltnet.json. The `insteadOf`
|
|
274
|
-
* rewrite rule and every other line are workspace-independent and pass through
|
|
275
|
-
* unchanged. A gitconfig without a credential helper is rewritten only for
|
|
276
|
-
* `signingKey`.
|
|
277
|
-
*
|
|
278
|
-
* This is the single source of truth for git push auth in the guest: the
|
|
279
|
-
* injected gitconfig carries the tokenless mint-on-demand helper, so the VM
|
|
280
|
-
* no longer hand-rolls a credential-helper script or runs an imperative
|
|
281
|
-
* `git config --global ... insteadOf` against the guest $HOME.
|
|
282
|
-
*/
|
|
283
|
-
export declare function rewriteGitconfigPaths(gitconfig: string, vmSshDir: string, vmAgentDir: string): string;
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Rewrite host-absolute paths inside moltnet.json to VM-local equivalents.
|
|
287
|
-
*
|
|
288
|
-
* Fields rewritten:
|
|
289
|
-
* ssh.private_key_path → <vmSshDir>/<basename of original>
|
|
290
|
-
* ssh.public_key_path → <vmSshDir>/<basename of original>
|
|
291
|
-
* git.config_path → <vmAgentDir>/gitconfig
|
|
292
|
-
* github.private_key_path → <vmAgentDir>/<pemFilename> (if present)
|
|
293
|
-
*
|
|
294
|
-
* All other fields are passed through unchanged.
|
|
295
|
-
* Throws if moltnetJson is not valid JSON — callers must not inject a broken
|
|
296
|
-
* moltnet.json into the guest.
|
|
297
|
-
*/
|
|
298
|
-
export declare function rewriteMoltnetJsonPaths(moltnetJson: string, vmAgentDir: string, vmSshDir: string, githubAppPemFilename: string | null): string;
|
|
299
|
-
|
|
300
318
|
export declare interface SandboxConfig {
|
|
301
319
|
/**
|
|
302
320
|
* Operator-owned snapshot build settings. Runtime profiles must never
|
|
@@ -386,12 +404,6 @@ export declare interface VmConfig {
|
|
|
386
404
|
checkpointPath: string;
|
|
387
405
|
/** MoltNet agent name (used to resolve credentials). */
|
|
388
406
|
agentName: string;
|
|
389
|
-
/**
|
|
390
|
-
* Trust boundary for guest credentials. `guest-config` injects the complete
|
|
391
|
-
* legacy agent directory. `host-authenticated` never reads or injects it and
|
|
392
|
-
* relies exclusively on the supplied host-side Agent for MoltNet operations.
|
|
393
|
-
*/
|
|
394
|
-
guestCredentialMode?: GuestCredentialMode;
|
|
395
407
|
/**
|
|
396
408
|
* Host root that owns `.moltnet/<agentName>/`.
|
|
397
409
|
*
|
|
@@ -438,43 +450,21 @@ export declare interface VmConfig {
|
|
|
438
450
|
onDiagnostic?: (diagnostic: VmDiagnostic) => void;
|
|
439
451
|
/** Abort resume/setup work, closing any live VM owned by resumeVm. */
|
|
440
452
|
signal?: AbortSignal;
|
|
441
|
-
/**
|
|
442
|
-
* Coding-agent provider authentication to place in the guest (for example
|
|
443
|
-
* Pi's `auth.json`). The sandbox knows nothing about the provider: the
|
|
444
|
-
* runtime above it supplies the loader and the guest path. When omitted,
|
|
445
|
-
* nothing is written and the guest relies on forwarded env providers.
|
|
446
|
-
*/
|
|
447
|
-
providerAuth?: ProviderAuthSource;
|
|
448
453
|
}
|
|
449
454
|
|
|
450
455
|
export declare interface VmCredentials {
|
|
451
|
-
/** Empty in host-authenticated mode; retained as strings for API stability. */
|
|
452
|
-
moltnetJson: string;
|
|
453
|
-
/** Empty in host-authenticated mode; retained as strings for API stability. */
|
|
454
|
-
agentEnvRaw: string;
|
|
455
456
|
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
* and the forwarded host environment instead. CI uses this path.
|
|
457
|
+
* Guest env carried across the boundary. Always empty: the guest receives no
|
|
458
|
+
* MoltNet credential material. Retained for API stability and because
|
|
459
|
+
* consumers read (empty) provider/diary/team hints from it.
|
|
460
460
|
*/
|
|
461
|
-
providerAuthJson: string | null;
|
|
462
461
|
agentEnv: Record<string, string | undefined>;
|
|
463
|
-
gitconfig: string | null;
|
|
464
|
-
sshPrivateKey: string | null;
|
|
465
|
-
sshPublicKey: string | null;
|
|
466
|
-
allowedSigners: string | null;
|
|
467
|
-
/** Raw PEM content of the GitHub App private key, or null if not configured. */
|
|
468
|
-
githubAppPem: string | null;
|
|
469
|
-
/** VM-local filename for the GitHub App PEM (basename of host path), or null. */
|
|
470
|
-
githubAppPemFilename: string | null;
|
|
471
462
|
}
|
|
472
463
|
|
|
473
464
|
export declare interface VmDiagnostic {
|
|
474
|
-
event: 'vm.credentials.mode' | 'vm.
|
|
465
|
+
event: 'vm.credentials.mode' | 'vm.http_secrets.bound' | 'vm.host_origins.bound' | 'vm.guest_projection.applied' | 'vm.guest_service.not_ready' | 'vm.network.policy_bound' | 'vm.network.origin_checked' | 'vm.network.origin_denied';
|
|
475
466
|
level: 'info' | 'warning';
|
|
476
467
|
message: string;
|
|
477
|
-
credentialMode: GuestCredentialMode;
|
|
478
468
|
/** Present only for the value-free broker summary event. */
|
|
479
469
|
brokeredSecretCount?: number;
|
|
480
470
|
/** Present only for the host-origins summary event. */
|
|
@@ -482,6 +472,19 @@ export declare interface VmDiagnostic {
|
|
|
482
472
|
/** Present only for the guest-projection summary event. */
|
|
483
473
|
projectedFileCount?: number;
|
|
484
474
|
projectedServiceCount?: number;
|
|
475
|
+
/** Complete value-free hostname inputs passed to Gondolin. */
|
|
476
|
+
hostnamePolicy?: {
|
|
477
|
+
allowedHosts: readonly string[];
|
|
478
|
+
allowedInternalHosts: readonly string[];
|
|
479
|
+
};
|
|
480
|
+
/** Canonical, value-free decision for an origin check or denial. */
|
|
481
|
+
origin?: {
|
|
482
|
+
hostname: string;
|
|
483
|
+
protocol: string;
|
|
484
|
+
port: number;
|
|
485
|
+
phase: 'request' | 'ip';
|
|
486
|
+
allowed: boolean;
|
|
487
|
+
};
|
|
485
488
|
}
|
|
486
489
|
|
|
487
490
|
export { }
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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
|
+
import { domainToASCII } from "node:url";
|
|
7
8
|
//#region src/abort-utils.ts
|
|
8
9
|
function throwIfAborted(signal, label) {
|
|
9
10
|
if (!signal?.aborted) return;
|
|
@@ -61,6 +62,138 @@ async function delay(ms, signal, label) {
|
|
|
61
62
|
});
|
|
62
63
|
}
|
|
63
64
|
//#endregion
|
|
65
|
+
//#region src/managed-exec.ts
|
|
66
|
+
function chunkBuffer(data) {
|
|
67
|
+
if (Buffer.isBuffer(data)) return data;
|
|
68
|
+
return typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
|
|
69
|
+
}
|
|
70
|
+
async function retireBackend(vm, trigger, onDiagnostic) {
|
|
71
|
+
onDiagnostic?.({
|
|
72
|
+
event: "managed_exec.backend_retirement",
|
|
73
|
+
trigger,
|
|
74
|
+
outcome: "started",
|
|
75
|
+
message: "retiring Gondolin VM after interrupted guest execution"
|
|
76
|
+
});
|
|
77
|
+
try {
|
|
78
|
+
await vm.close();
|
|
79
|
+
onDiagnostic?.({
|
|
80
|
+
event: "managed_exec.backend_retirement",
|
|
81
|
+
trigger,
|
|
82
|
+
outcome: "succeeded",
|
|
83
|
+
message: "Gondolin VM retirement completed"
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
status: "backend-retired",
|
|
87
|
+
mode: "vm-close"
|
|
88
|
+
};
|
|
89
|
+
} catch (error) {
|
|
90
|
+
onDiagnostic?.({
|
|
91
|
+
event: "managed_exec.backend_retirement",
|
|
92
|
+
trigger,
|
|
93
|
+
outcome: "failed",
|
|
94
|
+
message: error instanceof Error ? `Gondolin VM retirement failed: ${error.message}` : "Gondolin VM retirement failed"
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
status: "recovery-required",
|
|
98
|
+
reason: "backend-retirement-failed"
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Execute one login-shell command in a Gondolin VM. Successful commands may
|
|
104
|
+
* reuse the VM. Timeout or cancellation retires the complete VM through the
|
|
105
|
+
* host API before returning, because no guest process/session boundary can
|
|
106
|
+
* prove containment against daemonization or a guest-mutated root filesystem.
|
|
107
|
+
*/
|
|
108
|
+
async function execManagedCommand(vm, command, options = {}) {
|
|
109
|
+
if (options.signal?.aborted) return {
|
|
110
|
+
exitCode: 130,
|
|
111
|
+
timedOut: false,
|
|
112
|
+
cancelled: true,
|
|
113
|
+
termination: { status: "not-started" }
|
|
114
|
+
};
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
let timedOut = false;
|
|
117
|
+
let cancelled = false;
|
|
118
|
+
let executionSettled = false;
|
|
119
|
+
let acceptOutput = true;
|
|
120
|
+
let resolveInterrupted;
|
|
121
|
+
let retirementPromise;
|
|
122
|
+
const interrupted = new Promise((resolve) => {
|
|
123
|
+
resolveInterrupted = resolve;
|
|
124
|
+
});
|
|
125
|
+
const interrupt = (trigger) => {
|
|
126
|
+
if (executionSettled || retirementPromise) return;
|
|
127
|
+
acceptOutput = false;
|
|
128
|
+
controller.abort(/* @__PURE__ */ new Error(`guest command interrupted by ${trigger}`));
|
|
129
|
+
retirementPromise = retireBackend(vm, trigger, options.onDiagnostic);
|
|
130
|
+
resolveInterrupted?.();
|
|
131
|
+
};
|
|
132
|
+
const onAbort = () => {
|
|
133
|
+
cancelled = true;
|
|
134
|
+
interrupt("cancellation");
|
|
135
|
+
};
|
|
136
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
137
|
+
const timer = options.timeoutMs !== void 0 && options.timeoutMs > 0 ? setTimeout(() => {
|
|
138
|
+
timedOut = true;
|
|
139
|
+
interrupt("timeout");
|
|
140
|
+
}, options.timeoutMs) : void 0;
|
|
141
|
+
timer?.unref();
|
|
142
|
+
try {
|
|
143
|
+
const process = vm.exec([
|
|
144
|
+
"/bin/sh",
|
|
145
|
+
"-lc",
|
|
146
|
+
command
|
|
147
|
+
], {
|
|
148
|
+
cwd: options.cwd,
|
|
149
|
+
env: options.env,
|
|
150
|
+
signal: controller.signal,
|
|
151
|
+
stdout: "pipe",
|
|
152
|
+
stderr: "pipe"
|
|
153
|
+
});
|
|
154
|
+
options.onStarted?.();
|
|
155
|
+
const execution = (async () => {
|
|
156
|
+
for await (const chunk of process.output()) {
|
|
157
|
+
if (!acceptOutput) continue;
|
|
158
|
+
options.onData?.(chunkBuffer(chunk.data), chunk.stream);
|
|
159
|
+
}
|
|
160
|
+
return process;
|
|
161
|
+
})();
|
|
162
|
+
const observedExecution = execution.then((result) => {
|
|
163
|
+
executionSettled = true;
|
|
164
|
+
return {
|
|
165
|
+
kind: "result",
|
|
166
|
+
result
|
|
167
|
+
};
|
|
168
|
+
}, (error) => {
|
|
169
|
+
executionSettled = true;
|
|
170
|
+
return {
|
|
171
|
+
kind: "error",
|
|
172
|
+
error
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
const first = await Promise.race([observedExecution, interrupted.then(() => ({ kind: "interrupted" }))]);
|
|
176
|
+
if (first.kind === "result" && !timedOut && !cancelled) return {
|
|
177
|
+
exitCode: first.result.exitCode,
|
|
178
|
+
timedOut: false,
|
|
179
|
+
cancelled: false,
|
|
180
|
+
termination: { status: "not-required" }
|
|
181
|
+
};
|
|
182
|
+
if (first.kind === "error" && !timedOut && !cancelled) throw first.error;
|
|
183
|
+
execution.catch(() => void 0);
|
|
184
|
+
const termination = await (retirementPromise ??= retireBackend(vm, timedOut ? "timeout" : "cancellation", options.onDiagnostic));
|
|
185
|
+
return {
|
|
186
|
+
exitCode: timedOut ? 124 : 130,
|
|
187
|
+
timedOut,
|
|
188
|
+
cancelled: cancelled && !timedOut,
|
|
189
|
+
termination
|
|
190
|
+
};
|
|
191
|
+
} finally {
|
|
192
|
+
if (timer) clearTimeout(timer);
|
|
193
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
64
197
|
//#region src/path-containment.ts
|
|
65
198
|
/**
|
|
66
199
|
* Check containment for already-resolved lexical or real paths.
|
|
@@ -293,6 +426,76 @@ function pruneOldSnapshots(maxCached, currentDir) {
|
|
|
293
426
|
});
|
|
294
427
|
}
|
|
295
428
|
//#endregion
|
|
429
|
+
//#region src/canonical-host.ts
|
|
430
|
+
var DECIMAL_IPV4_COMPONENTS = /^\d+(?:\.\d+){0,3}$/;
|
|
431
|
+
var NON_DECIMAL_IPV4 = /^(?:0x[\da-f]+|0[0-7]+)(?:\.(?:0x[\da-f]+|0[0-7]+|\d+)){0,3}$/i;
|
|
432
|
+
function stripTrailingDots(value) {
|
|
433
|
+
let end = value.length;
|
|
434
|
+
while (end > 0 && value.charCodeAt(end - 1) === 46) end -= 1;
|
|
435
|
+
return value.slice(0, end);
|
|
436
|
+
}
|
|
437
|
+
function rejectAlternateNumericAddress(hostname) {
|
|
438
|
+
if (isIP(hostname) === 0 && (DECIMAL_IPV4_COMPONENTS.test(hostname) || NON_DECIMAL_IPV4.test(hostname) || /^\d+$/.test(hostname))) throw new Error("alternate numeric IP forms are not admitted");
|
|
439
|
+
}
|
|
440
|
+
/** Canonicalize one concrete DNS name or IP literal for policy comparison. */
|
|
441
|
+
function canonicalizeHostname(input) {
|
|
442
|
+
const withoutTrailingDot = stripTrailingDots(input.trim().replace(/^\[|\]$/g, ""));
|
|
443
|
+
if (!withoutTrailingDot) throw new Error("hostname is required");
|
|
444
|
+
rejectAlternateNumericAddress(withoutTrailingDot);
|
|
445
|
+
const ipVersion = isIP(withoutTrailingDot);
|
|
446
|
+
if (ipVersion === 4) return withoutTrailingDot;
|
|
447
|
+
if (ipVersion === 6) return new URL(`http://[${withoutTrailingDot}]/`).hostname.slice(1, -1);
|
|
448
|
+
if (/[\s/:@?#[\]]/.test(withoutTrailingDot)) throw new Error("invalid hostname");
|
|
449
|
+
const ascii = domainToASCII(withoutTrailingDot.toLowerCase());
|
|
450
|
+
if (!ascii || ascii.length > 253) throw new Error("invalid hostname");
|
|
451
|
+
return ascii;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Credential destinations deliberately support only exact hosts, a global
|
|
455
|
+
* wildcard, or a leading one-label wildcard. Arbitrary globs are too
|
|
456
|
+
* ambiguous for a secret-delivery boundary.
|
|
457
|
+
*/
|
|
458
|
+
function canonicalizeCredentialHostPattern(input) {
|
|
459
|
+
const pattern = input.trim();
|
|
460
|
+
if (pattern === "*") return pattern;
|
|
461
|
+
if (pattern.startsWith("*.") && pattern.indexOf("*", 1) === -1) return `*.${canonicalizeHostname(pattern.slice(2))}`;
|
|
462
|
+
if (pattern.includes("*")) throw new Error("credential host patterns allow only a leading *.");
|
|
463
|
+
return canonicalizeHostname(pattern);
|
|
464
|
+
}
|
|
465
|
+
function credentialHostMatches(hostnameInput, patternInput) {
|
|
466
|
+
const hostname = canonicalizeHostname(hostnameInput);
|
|
467
|
+
const pattern = canonicalizeCredentialHostPattern(patternInput);
|
|
468
|
+
if (pattern === "*") return true;
|
|
469
|
+
if (!pattern.startsWith("*.")) return hostname === pattern;
|
|
470
|
+
const suffix = pattern.slice(2);
|
|
471
|
+
if (!hostname.endsWith(`.${suffix}`)) return false;
|
|
472
|
+
const prefix = hostname.slice(0, -(suffix.length + 1));
|
|
473
|
+
return prefix.length > 0 && !prefix.includes(".");
|
|
474
|
+
}
|
|
475
|
+
/** Keep Gondolin's wider network glob syntax separate from secret patterns. */
|
|
476
|
+
function normalizeNetworkHostPattern(input) {
|
|
477
|
+
const pattern = input.trim();
|
|
478
|
+
if (pattern === "*") return pattern;
|
|
479
|
+
if (!pattern.includes("*")) return canonicalizeHostname(pattern);
|
|
480
|
+
const normalized = stripTrailingDots(pattern.toLowerCase());
|
|
481
|
+
if (normalized === "" || normalized.includes("://") || normalized.includes("/") || normalized.includes(":") || /\s/.test(normalized)) throw new Error("invalid network host pattern");
|
|
482
|
+
return normalized;
|
|
483
|
+
}
|
|
484
|
+
function networkPatternMatchesHostname(networkPatternInput, hostnameInput) {
|
|
485
|
+
const networkPattern = normalizeNetworkHostPattern(networkPatternInput);
|
|
486
|
+
const hostname = canonicalizeHostname(hostnameInput);
|
|
487
|
+
const expression = networkPattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
488
|
+
return new RegExp(`^${expression}$`, "i").test(hostname);
|
|
489
|
+
}
|
|
490
|
+
/** Prove that a network grant covers a narrower credential destination. */
|
|
491
|
+
function networkPatternCoversCredentialPattern(networkPatternInput, credentialPatternInput) {
|
|
492
|
+
const networkPattern = normalizeNetworkHostPattern(networkPatternInput);
|
|
493
|
+
const credentialPattern = canonicalizeCredentialHostPattern(credentialPatternInput);
|
|
494
|
+
if (networkPattern === "*") return true;
|
|
495
|
+
if (credentialPattern.includes("*")) return networkPattern === credentialPattern;
|
|
496
|
+
return networkPatternMatchesHostname(networkPattern, credentialPattern);
|
|
497
|
+
}
|
|
498
|
+
//#endregion
|
|
296
499
|
//#region src/host-origins.ts
|
|
297
500
|
function hostOriginHostnames(origins) {
|
|
298
501
|
return Object.keys(origins).map((origin) => new URL(origin).hostname).sort();
|
|
@@ -432,59 +635,8 @@ function resolveVmAgentDir(config) {
|
|
|
432
635
|
const rootDir = config.agentRootDir ?? findMainWorktree();
|
|
433
636
|
return path.join(rootDir, ".moltnet", config.agentName);
|
|
434
637
|
}
|
|
435
|
-
function loadCredentials(
|
|
436
|
-
|
|
437
|
-
const agentEnvPath = path.join(agentDir, "env");
|
|
438
|
-
const providerAuthJson = providerAuth?.load() ?? null;
|
|
439
|
-
if (mode === "host-authenticated") return {
|
|
440
|
-
moltnetJson: "",
|
|
441
|
-
agentEnvRaw: "",
|
|
442
|
-
providerAuthJson,
|
|
443
|
-
agentEnv: {},
|
|
444
|
-
gitconfig: null,
|
|
445
|
-
sshPrivateKey: null,
|
|
446
|
-
sshPublicKey: null,
|
|
447
|
-
allowedSigners: null,
|
|
448
|
-
githubAppPem: null,
|
|
449
|
-
githubAppPemFilename: null
|
|
450
|
-
};
|
|
451
|
-
const hasMoltnetJson = existsSync(moltnetPath);
|
|
452
|
-
const hasAgentEnv = existsSync(agentEnvPath);
|
|
453
|
-
if (!hasMoltnetJson || !hasAgentEnv) throw new Error(`Guest credential mode requires both ${moltnetPath} and ${agentEnvPath}`);
|
|
454
|
-
const moltnetJson = readFileSync(moltnetPath, "utf8");
|
|
455
|
-
const agentEnvRaw = readFileSync(agentEnvPath, "utf8");
|
|
456
|
-
if (moltnetJson.trim() === "") throw new Error(`Agent configuration is empty: ${moltnetPath}`);
|
|
457
|
-
const gitconfigPath = path.join(agentDir, "gitconfig");
|
|
458
|
-
const gitconfig = existsSync(gitconfigPath) ? readFileSync(gitconfigPath, "utf8") : null;
|
|
459
|
-
const sshDir = path.join(agentDir, "ssh");
|
|
460
|
-
const sshPrivateKey = existsSync(path.join(sshDir, "id_ed25519")) ? readFileSync(path.join(sshDir, "id_ed25519"), "utf8") : null;
|
|
461
|
-
const sshPublicKey = existsSync(path.join(sshDir, "id_ed25519.pub")) ? readFileSync(path.join(sshDir, "id_ed25519.pub"), "utf8") : null;
|
|
462
|
-
const allowedSigners = existsSync(path.join(sshDir, "allowed_signers")) ? readFileSync(path.join(sshDir, "allowed_signers"), "utf8") : null;
|
|
463
|
-
let githubAppPem = null;
|
|
464
|
-
let githubAppPemFilename = null;
|
|
465
|
-
const pemPath = (moltnetJson ? JSON.parse(moltnetJson) : null)?.github?.private_key_path;
|
|
466
|
-
if (pemPath) if (!existsSync(pemPath)) onDiagnostic?.({
|
|
467
|
-
event: "vm.credentials.github_key_missing",
|
|
468
|
-
level: "warning",
|
|
469
|
-
credentialMode: mode,
|
|
470
|
-
message: `github.private_key_path not found at ${pemPath}; moltnet github token will fail inside the guest`
|
|
471
|
-
});
|
|
472
|
-
else {
|
|
473
|
-
githubAppPem = readFileSync(pemPath, "utf8");
|
|
474
|
-
githubAppPemFilename = path.basename(pemPath);
|
|
475
|
-
}
|
|
476
|
-
return {
|
|
477
|
-
moltnetJson,
|
|
478
|
-
agentEnvRaw,
|
|
479
|
-
providerAuthJson,
|
|
480
|
-
agentEnv: parseEnv(agentEnvRaw),
|
|
481
|
-
gitconfig,
|
|
482
|
-
sshPrivateKey,
|
|
483
|
-
sshPublicKey,
|
|
484
|
-
allowedSigners,
|
|
485
|
-
githubAppPem,
|
|
486
|
-
githubAppPemFilename
|
|
487
|
-
};
|
|
638
|
+
function loadCredentials() {
|
|
639
|
+
return { agentEnv: {} };
|
|
488
640
|
}
|
|
489
641
|
/**
|
|
490
642
|
* Apply agent env vars to the host process, mirroring `moltnet start`.
|
|
@@ -579,12 +731,6 @@ var OBJECT_META_PROPERTY_NAMES = new Set([
|
|
|
579
731
|
"constructor",
|
|
580
732
|
"prototype"
|
|
581
733
|
]);
|
|
582
|
-
function normalizeHostPattern(pattern) {
|
|
583
|
-
return pattern.trim().toLowerCase();
|
|
584
|
-
}
|
|
585
|
-
function isValidHostPattern(pattern) {
|
|
586
|
-
return pattern !== "" && !pattern.includes("://") && !pattern.includes("/") && !pattern.includes(":") && !/\s/.test(pattern);
|
|
587
|
-
}
|
|
588
734
|
/**
|
|
589
735
|
* Canonicalize and validate the value-free portion of a brokered binding.
|
|
590
736
|
* Runtime attestation and Gondolin enforcement both consume this function so
|
|
@@ -594,15 +740,20 @@ function canonicalizeBrokeredHttpSecretDescriptor(descriptor) {
|
|
|
594
740
|
const issues = [];
|
|
595
741
|
const id = descriptor.id.trim();
|
|
596
742
|
const guestEnv = descriptor.guestEnv.trim();
|
|
597
|
-
const hosts = [
|
|
743
|
+
const hosts = [];
|
|
598
744
|
const protocolInput = descriptor.protocol ?? "https";
|
|
599
745
|
const protocol = protocolInput === "http" ? "http" : "https";
|
|
600
746
|
const ports = [...new Set(descriptor.ports ?? [protocol === "https" ? 443 : 80])].sort((left, right) => left - right);
|
|
601
747
|
if (!BROKERED_SECRET_ID_REGEXP.test(id)) issues.push(`invalid requirement id "${id || "<empty>"}"`);
|
|
602
748
|
if (!GUEST_ENV_NAME_REGEXP.test(guestEnv)) issues.push(`requirement "${id}" has invalid guest env "${guestEnv}"`);
|
|
603
749
|
else if (isReservedGuestEnvironmentName(guestEnv) || OBJECT_META_PROPERTY_NAMES.has(guestEnv)) issues.push(`requirement "${id}" uses reserved guest env "${guestEnv}"`);
|
|
604
|
-
|
|
605
|
-
|
|
750
|
+
for (const hostInput of descriptor.hosts) try {
|
|
751
|
+
hosts.push(canonicalizeCredentialHostPattern(hostInput));
|
|
752
|
+
} catch {
|
|
753
|
+
issues.push(`requirement "${id}" has invalid host pattern "${hostInput.trim()}"`);
|
|
754
|
+
}
|
|
755
|
+
const uniqueHosts = [...new Set(hosts)].sort();
|
|
756
|
+
if (descriptor.hosts.length === 0) issues.push(`requirement "${id}" has no destination hosts`);
|
|
606
757
|
if (protocolInput !== "https" && protocolInput !== "http") issues.push(`requirement "${id}" has invalid protocol "${String(protocolInput)}"`);
|
|
607
758
|
if (ports.length === 0) issues.push(`requirement "${id}" has no destination ports`);
|
|
608
759
|
for (const port of ports) if (!Number.isInteger(port) || port < 1 || port > 65535) issues.push(`requirement "${id}" has invalid port "${port}"`);
|
|
@@ -610,16 +761,12 @@ function canonicalizeBrokeredHttpSecretDescriptor(descriptor) {
|
|
|
610
761
|
return Object.freeze({
|
|
611
762
|
id,
|
|
612
763
|
guestEnv,
|
|
613
|
-
hosts: Object.freeze(
|
|
764
|
+
hosts: Object.freeze(uniqueHosts),
|
|
614
765
|
protocol,
|
|
615
766
|
ports: Object.freeze(ports),
|
|
616
767
|
required: descriptor.required !== false
|
|
617
768
|
});
|
|
618
769
|
}
|
|
619
|
-
function hostMatchesPattern(hostname, pattern) {
|
|
620
|
-
const expression = pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
621
|
-
return new RegExp(`^${expression}$`, "i").test(hostname);
|
|
622
|
-
}
|
|
623
770
|
/**
|
|
624
771
|
* Conservative subset check for Gondolin hostname globs. Exact secret hosts
|
|
625
772
|
* may sit below a broader network wildcard. A wildcard secret destination must
|
|
@@ -627,9 +774,7 @@ function hostMatchesPattern(hostname, pattern) {
|
|
|
627
774
|
* through an ambiguous glob comparison.
|
|
628
775
|
*/
|
|
629
776
|
function networkPatternCoversSecretPattern(networkPattern, secretPattern) {
|
|
630
|
-
|
|
631
|
-
if (secretPattern.includes("*")) return networkPattern === secretPattern;
|
|
632
|
-
return hostMatchesPattern(secretPattern, networkPattern);
|
|
777
|
+
return networkPatternCoversCredentialPattern(networkPattern, secretPattern);
|
|
633
778
|
}
|
|
634
779
|
/**
|
|
635
780
|
* Validate value-free descriptors, host-local bindings, environment
|
|
@@ -640,7 +785,7 @@ function prepareBrokeredHttpSecrets(options) {
|
|
|
640
785
|
const ids = /* @__PURE__ */ new Set();
|
|
641
786
|
const guestEnvNames = /* @__PURE__ */ new Set();
|
|
642
787
|
const occupiedGuestEnvNames = new Set(options.occupiedGuestEnvNames ?? []);
|
|
643
|
-
const allowedHosts = [...new Set(options.allowedHosts.map(
|
|
788
|
+
const allowedHosts = [...new Set(options.allowedHosts.map(normalizeNetworkHostPattern))];
|
|
644
789
|
const secrets = Object.create(null);
|
|
645
790
|
for (const binding of options.bindings ?? []) {
|
|
646
791
|
let descriptor;
|
|
@@ -690,6 +835,19 @@ function headersContainSecretValue(headers, value) {
|
|
|
690
835
|
}
|
|
691
836
|
return false;
|
|
692
837
|
}
|
|
838
|
+
function parseRequestOrigin(url) {
|
|
839
|
+
try {
|
|
840
|
+
const parsed = new URL(url);
|
|
841
|
+
const protocol = parsed.protocol.slice(0, -1);
|
|
842
|
+
return {
|
|
843
|
+
hostname: canonicalizeHostname(parsed.hostname),
|
|
844
|
+
protocol,
|
|
845
|
+
port: parsed.port ? Number(parsed.port) : protocol === "https" ? 443 : 80
|
|
846
|
+
};
|
|
847
|
+
} catch {
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
693
851
|
function createBrokeredHttpSecretOriginPolicy(bindings) {
|
|
694
852
|
const entries = /* @__PURE__ */ new Map();
|
|
695
853
|
for (const binding of bindings) {
|
|
@@ -706,17 +864,11 @@ function createBrokeredHttpSecretOriginPolicy(bindings) {
|
|
|
706
864
|
}
|
|
707
865
|
return {
|
|
708
866
|
isRequestAllowed(request) {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
url = new URL(request.url);
|
|
712
|
-
} catch {
|
|
713
|
-
return false;
|
|
714
|
-
}
|
|
715
|
-
const protocol = url.protocol.slice(0, -1);
|
|
716
|
-
const port = url.port ? Number(url.port) : protocol === "https" ? 443 : 80;
|
|
867
|
+
const requestOrigin = parseRequestOrigin(request.url);
|
|
868
|
+
if (!requestOrigin) return false;
|
|
717
869
|
for (const entry of entries.values()) {
|
|
718
870
|
if (entry.deleted || !headersContainSecretValue(request.headers, entry.value)) continue;
|
|
719
|
-
if (protocol !== entry.protocol || !entry.ports.includes(port) || !entry.hosts.some((host) =>
|
|
871
|
+
if (requestOrigin.protocol !== entry.protocol || !entry.ports.includes(requestOrigin.port) || !entry.hosts.some((host) => credentialHostMatches(requestOrigin.hostname, host))) return false;
|
|
720
872
|
}
|
|
721
873
|
return true;
|
|
722
874
|
},
|
|
@@ -733,18 +885,57 @@ function createBrokeredHttpSecretOriginPolicy(bindings) {
|
|
|
733
885
|
}
|
|
734
886
|
};
|
|
735
887
|
}
|
|
888
|
+
/**
|
|
889
|
+
* Refine Gondolin's hostname allowlist for hosts carrying brokered
|
|
890
|
+
* credentials. The built-in allowlist is hostname-granular; these callbacks
|
|
891
|
+
* enforce the descriptor protocol and port before request or IP dispatch.
|
|
892
|
+
*/
|
|
893
|
+
function createBrokeredHttpNetworkOriginPolicy(bindings, options = {}) {
|
|
894
|
+
const origins = bindings.map(canonicalizeBrokeredHttpSecretDescriptor);
|
|
895
|
+
const isAllowed = (input) => {
|
|
896
|
+
let hostname;
|
|
897
|
+
try {
|
|
898
|
+
hostname = canonicalizeHostname(input.hostname);
|
|
899
|
+
} catch {
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
const matching = origins.filter((origin) => origin.hosts.some((host) => credentialHostMatches(hostname, host)));
|
|
903
|
+
if (matching.length === 0) return true;
|
|
904
|
+
const allowed = matching.some((origin) => origin.protocol === input.protocol && origin.ports.includes(input.port));
|
|
905
|
+
options.onDecision?.({
|
|
906
|
+
hostname,
|
|
907
|
+
protocol: input.protocol,
|
|
908
|
+
port: input.port,
|
|
909
|
+
phase: input.phase,
|
|
910
|
+
allowed
|
|
911
|
+
});
|
|
912
|
+
return allowed;
|
|
913
|
+
};
|
|
914
|
+
return {
|
|
915
|
+
isRequestAllowed(request) {
|
|
916
|
+
const requestOrigin = parseRequestOrigin(request.url);
|
|
917
|
+
return requestOrigin ? isAllowed({
|
|
918
|
+
...requestOrigin,
|
|
919
|
+
phase: "request"
|
|
920
|
+
}) : false;
|
|
921
|
+
},
|
|
922
|
+
isIpAllowed(info) {
|
|
923
|
+
return isAllowed({
|
|
924
|
+
...info,
|
|
925
|
+
phase: "ip"
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
}
|
|
736
930
|
function assertGuestEnvironmentBoundary(options) {
|
|
737
|
-
const refusedForwardEnv = (options.forwardEnv ?? []).filter((name) => isReservedGuestEnvironmentName(name) ||
|
|
931
|
+
const refusedForwardEnv = (options.forwardEnv ?? []).filter((name) => isReservedGuestEnvironmentName(name) || !HOST_AUTHENTICATED_GUEST_ENV_ALLOWLIST.has(name));
|
|
738
932
|
const refusedSandboxEnv = Object.keys(options.sandboxEnv ?? {}).filter(isReservedGuestEnvironmentName);
|
|
739
933
|
const refused = [...new Set([...refusedForwardEnv, ...refusedSandboxEnv])].sort();
|
|
740
934
|
if (refused.length > 0) throw new GuestEnvironmentBoundaryError(refused);
|
|
741
935
|
}
|
|
742
|
-
/** @deprecated
|
|
936
|
+
/** @deprecated Alias of assertGuestEnvironmentBoundary (a single boundary). */
|
|
743
937
|
function assertHostAuthenticatedGuestEnvironment(options) {
|
|
744
|
-
assertGuestEnvironmentBoundary(
|
|
745
|
-
guestCredentialMode: "host-authenticated",
|
|
746
|
-
...options
|
|
747
|
-
});
|
|
938
|
+
assertGuestEnvironmentBoundary(options);
|
|
748
939
|
}
|
|
749
940
|
/**
|
|
750
941
|
* Return whether two Gondolin hostname globs can match at least one common
|
|
@@ -821,22 +1012,17 @@ async function resumeVm(config) {
|
|
|
821
1012
|
throwIfAborted(config.signal, "VM resume");
|
|
822
1013
|
const agentDir = resolveVmAgentDir(config);
|
|
823
1014
|
const guestWorkspace = path.resolve(config.mountPath);
|
|
824
|
-
const guestCredentialMode = config.guestCredentialMode ?? "guest-config";
|
|
825
|
-
if (guestCredentialMode === "guest-config" && !existsSync(agentDir)) throw new Error(`Agent directory not found: ${agentDir}. Run: moltnet register --name ${config.agentName}`);
|
|
826
1015
|
assertGuestEnvironmentBoundary({
|
|
827
|
-
guestCredentialMode,
|
|
828
1016
|
forwardEnv: config.forwardEnv,
|
|
829
1017
|
sandboxEnv: config.sandboxConfig?.env
|
|
830
1018
|
});
|
|
831
1019
|
config.onDiagnostic?.({
|
|
832
1020
|
event: "vm.credentials.mode",
|
|
833
1021
|
level: "info",
|
|
834
|
-
|
|
835
|
-
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"
|
|
1022
|
+
message: "MoltNet agent files and non-allowlisted host environment variables are withheld from the guest"
|
|
836
1023
|
});
|
|
837
|
-
const creds = loadCredentials(
|
|
838
|
-
const
|
|
839
|
-
const apiHost = new URL(configuredApiUrl ?? process.env.MOLTNET_API_URL ?? DEFAULT_MOLTNET_API_URL).hostname;
|
|
1024
|
+
const creds = loadCredentials();
|
|
1025
|
+
const apiHost = new URL(process.env.MOLTNET_API_URL ?? DEFAULT_MOLTNET_API_URL).hostname;
|
|
840
1026
|
const runtimeAllowedHosts = config.sandboxConfig?.network?.allowedHosts ?? [];
|
|
841
1027
|
const runtimeAllowedInternalHosts = config.sandboxConfig?.network?.allowedInternalHosts ?? [];
|
|
842
1028
|
const protectedExternalHosts = [...new Set([
|
|
@@ -850,6 +1036,16 @@ async function resumeVm(config) {
|
|
|
850
1036
|
const hostOriginHosts = hostOriginHostnames(hostOrigins);
|
|
851
1037
|
assertInternalHostsDoNotOverlapProtectedHosts(hostOriginHosts, protectedExternalHosts);
|
|
852
1038
|
const allowedInternalHosts = [...new Set([...runtimeAllowedInternalHosts, ...hostOriginHosts])];
|
|
1039
|
+
const requestedHostnamePolicy = Object.freeze({
|
|
1040
|
+
allowedHosts: Object.freeze([...allowedHosts].sort()),
|
|
1041
|
+
allowedInternalHosts: Object.freeze([...allowedInternalHosts].sort())
|
|
1042
|
+
});
|
|
1043
|
+
config.onDiagnostic?.({
|
|
1044
|
+
event: "vm.network.policy_bound",
|
|
1045
|
+
level: "info",
|
|
1046
|
+
message: "Passed the complete requested hostname policy to Gondolin",
|
|
1047
|
+
hostnamePolicy: requestedHostnamePolicy
|
|
1048
|
+
});
|
|
853
1049
|
const projectedEnv = config.guestProjection?.env ?? {};
|
|
854
1050
|
const brokeredSecrets = prepareBrokeredHttpSecrets({
|
|
855
1051
|
bindings: config.brokeredSecrets,
|
|
@@ -867,12 +1063,27 @@ async function resumeVm(config) {
|
|
|
867
1063
|
]
|
|
868
1064
|
});
|
|
869
1065
|
const brokeredSecretOriginPolicy = createBrokeredHttpSecretOriginPolicy(config.brokeredSecrets ?? []);
|
|
1066
|
+
const brokeredNetworkOriginPolicy = createBrokeredHttpNetworkOriginPolicy(config.brokeredSecrets ?? [], { onDecision(decision) {
|
|
1067
|
+
config.onDiagnostic?.({
|
|
1068
|
+
event: "vm.network.origin_checked",
|
|
1069
|
+
level: "info",
|
|
1070
|
+
message: "Checked a canonical brokered credential origin",
|
|
1071
|
+
origin: decision
|
|
1072
|
+
});
|
|
1073
|
+
if (!decision.allowed) config.onDiagnostic?.({
|
|
1074
|
+
event: "vm.network.origin_denied",
|
|
1075
|
+
level: "warning",
|
|
1076
|
+
message: "Denied a request outside a brokered credential origin",
|
|
1077
|
+
origin: decision
|
|
1078
|
+
});
|
|
1079
|
+
} });
|
|
870
1080
|
const { httpHooks, env: secretEnv, secretManager: gondolinSecretManager } = createHttpHooks({
|
|
871
1081
|
allowedHosts,
|
|
872
1082
|
allowedInternalHosts,
|
|
873
1083
|
...Object.keys(brokeredSecrets).length > 0 && {
|
|
874
1084
|
secrets: brokeredSecrets,
|
|
875
|
-
isRequestAllowed: brokeredSecretOriginPolicy.isRequestAllowed
|
|
1085
|
+
isRequestAllowed: (request) => brokeredNetworkOriginPolicy.isRequestAllowed(request) && brokeredSecretOriginPolicy.isRequestAllowed(request),
|
|
1086
|
+
isIpAllowed: brokeredNetworkOriginPolicy.isIpAllowed
|
|
876
1087
|
},
|
|
877
1088
|
...hostOriginHosts.length > 0 && { onRequest: createHostOriginsOnRequest(hostOrigins) }
|
|
878
1089
|
});
|
|
@@ -890,26 +1101,15 @@ async function resumeVm(config) {
|
|
|
890
1101
|
if (brokeredSecretCount > 0) config.onDiagnostic?.({
|
|
891
1102
|
event: "vm.http_secrets.bound",
|
|
892
1103
|
level: "info",
|
|
893
|
-
credentialMode: guestCredentialMode,
|
|
894
1104
|
brokeredSecretCount,
|
|
895
1105
|
message: `Bound ${brokeredSecretCount} HTTP secret placeholder${brokeredSecretCount === 1 ? "" : "s"} to the host proxy`
|
|
896
1106
|
});
|
|
897
1107
|
if (hostOriginHosts.length > 0) config.onDiagnostic?.({
|
|
898
1108
|
event: "vm.host_origins.bound",
|
|
899
1109
|
level: "info",
|
|
900
|
-
credentialMode: guestCredentialMode,
|
|
901
1110
|
hostOriginCount: hostOriginHosts.length,
|
|
902
1111
|
message: `Bound ${hostOriginHosts.length} host origin${hostOriginHosts.length === 1 ? "" : "s"} to the host proxy`
|
|
903
1112
|
});
|
|
904
|
-
const vmAgentDir = `/home/agent/.moltnet/${config.agentName}`;
|
|
905
|
-
const vmAgentEnv = {};
|
|
906
|
-
for (const [k, v] of Object.entries(creds.agentEnv)) {
|
|
907
|
-
if (v === void 0 || v === "") continue;
|
|
908
|
-
if (k === "GIT_CONFIG_GLOBAL") vmAgentEnv[k] = `${vmAgentDir}/gitconfig`;
|
|
909
|
-
else if (k.endsWith("_PRIVATE_KEY_PATH")) vmAgentEnv[k] = `${vmAgentDir}/${path.basename(v)}`;
|
|
910
|
-
else vmAgentEnv[k] = v;
|
|
911
|
-
}
|
|
912
|
-
if (creds.moltnetJson) vmAgentEnv.MOLTNET_CREDENTIALS_PATH = `${vmAgentDir}/moltnet.json`;
|
|
913
1113
|
const vfsConfig = resolveVfsShadowConfig(config.sandboxConfig);
|
|
914
1114
|
let workspaceProvider = new RealFSProvider(config.mountPath);
|
|
915
1115
|
workspaceProvider = new ShadowProvider(workspaceProvider, {
|
|
@@ -925,7 +1125,7 @@ async function resumeVm(config) {
|
|
|
925
1125
|
writeMode: vfsConfig.mode
|
|
926
1126
|
});
|
|
927
1127
|
}
|
|
928
|
-
|
|
1128
|
+
workspaceProvider = new ShadowProvider(workspaceProvider, {
|
|
929
1129
|
shouldShadow: ({ path: shadowPath }) => shadowPath.split("/").includes(".moltnet"),
|
|
930
1130
|
denySymlinkBypass: true,
|
|
931
1131
|
writeMode: "deny"
|
|
@@ -938,7 +1138,6 @@ async function resumeVm(config) {
|
|
|
938
1138
|
}
|
|
939
1139
|
const envOverrides = config.sandboxConfig?.env ?? {};
|
|
940
1140
|
const vmEnv = {
|
|
941
|
-
...vmAgentEnv,
|
|
942
1141
|
...forwardedEnv,
|
|
943
1142
|
PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/go/bin",
|
|
944
1143
|
HOME: "/home/agent",
|
|
@@ -1023,55 +1222,10 @@ async function resumeVm(config) {
|
|
|
1023
1222
|
}
|
|
1024
1223
|
if (lastErr) throw lastErr instanceof Error ? lastErr : new Error(nonErrorMessage(lastErr));
|
|
1025
1224
|
}
|
|
1026
|
-
const vmSshDir = `${vmAgentDir}/ssh`;
|
|
1027
|
-
const hasAgentFiles = guestCredentialMode === "guest-config";
|
|
1028
|
-
const providerAuthDir = config.providerAuth ? path.posix.dirname(config.providerAuth.guestPath) : null;
|
|
1029
1225
|
const projectedFiles = config.guestProjection?.files ?? [];
|
|
1030
1226
|
const projectedDirs = [...new Set(projectedFiles.map((file) => path.posix.dirname(file.path)))];
|
|
1031
|
-
const guestDirs = [
|
|
1032
|
-
...hasAgentFiles ? [`${vmAgentDir}/ssh`] : [],
|
|
1033
|
-
...providerAuthDir ? [providerAuthDir] : [],
|
|
1034
|
-
...projectedDirs
|
|
1035
|
-
];
|
|
1227
|
+
const guestDirs = [...projectedDirs];
|
|
1036
1228
|
if (guestDirs.length > 0) await vmRun(vm, "create guest directories", `mkdir -p ${guestDirs.map(shellQuote).join(" ")}`, config.signal);
|
|
1037
|
-
if (creds.providerAuthJson !== null && config.providerAuth) await vm.fs.writeFile(config.providerAuth.guestPath, creds.providerAuthJson, {
|
|
1038
|
-
mode: 384,
|
|
1039
|
-
signal: config.signal
|
|
1040
|
-
});
|
|
1041
|
-
if (hasAgentFiles) {
|
|
1042
|
-
const vmMoltnetJson = rewriteMoltnetJsonPaths(creds.moltnetJson, vmAgentDir, vmSshDir, creds.githubAppPemFilename);
|
|
1043
|
-
await vm.fs.writeFile(`${vmAgentDir}/moltnet.json`, vmMoltnetJson, {
|
|
1044
|
-
mode: 384,
|
|
1045
|
-
signal: config.signal
|
|
1046
|
-
});
|
|
1047
|
-
await vm.fs.writeFile(`${vmAgentDir}/env`, creds.agentEnvRaw, {
|
|
1048
|
-
mode: 384,
|
|
1049
|
-
signal: config.signal
|
|
1050
|
-
});
|
|
1051
|
-
if (creds.gitconfig) {
|
|
1052
|
-
const vmGitconfig = rewriteGitconfigPaths(creds.gitconfig, vmSshDir, vmAgentDir);
|
|
1053
|
-
await vm.fs.writeFile(`${vmAgentDir}/gitconfig`, vmGitconfig, {
|
|
1054
|
-
mode: 420,
|
|
1055
|
-
signal: config.signal
|
|
1056
|
-
});
|
|
1057
|
-
}
|
|
1058
|
-
if (creds.sshPrivateKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519`, creds.sshPrivateKey, {
|
|
1059
|
-
mode: 384,
|
|
1060
|
-
signal: config.signal
|
|
1061
|
-
});
|
|
1062
|
-
if (creds.sshPublicKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519.pub`, creds.sshPublicKey, {
|
|
1063
|
-
mode: 420,
|
|
1064
|
-
signal: config.signal
|
|
1065
|
-
});
|
|
1066
|
-
if (creds.allowedSigners) await vm.fs.writeFile(`${vmSshDir}/allowed_signers`, creds.allowedSigners, {
|
|
1067
|
-
mode: 420,
|
|
1068
|
-
signal: config.signal
|
|
1069
|
-
});
|
|
1070
|
-
if (creds.githubAppPem && creds.githubAppPemFilename) await vm.fs.writeFile(`${vmAgentDir}/${creds.githubAppPemFilename}`, creds.githubAppPem, {
|
|
1071
|
-
mode: 384,
|
|
1072
|
-
signal: config.signal
|
|
1073
|
-
});
|
|
1074
|
-
}
|
|
1075
1229
|
for (const file of projectedFiles) {
|
|
1076
1230
|
await vm.fs.writeFile(file.path, file.content, {
|
|
1077
1231
|
mode: file.mode ?? 420,
|
|
@@ -1079,11 +1233,7 @@ async function resumeVm(config) {
|
|
|
1079
1233
|
});
|
|
1080
1234
|
if (file.mode !== void 0) await vmRun(vm, `chmod projected file ${file.path}`, `chmod ${file.mode.toString(8)} ${shellQuote(file.path)}`, config.signal);
|
|
1081
1235
|
}
|
|
1082
|
-
await vmRun(vm, "chown guest directories", `set -e; for d in ${[
|
|
1083
|
-
"/home/agent/.pi",
|
|
1084
|
-
...hasAgentFiles ? ["/home/agent/.moltnet"] : [],
|
|
1085
|
-
...projectedDirs.filter((dir) => dir.startsWith("/home/agent/"))
|
|
1086
|
-
].map(shellQuote).join(" ")}; do if [ -e "$d" ]; then chown -R agent:agent "$d"; fi; done`, config.signal);
|
|
1236
|
+
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);
|
|
1087
1237
|
const projectedServices = config.guestProjection?.services ?? [];
|
|
1088
1238
|
if (projectedServices.length > 0) await vmRun(vm, "create service pid directory", `mkdir -p ${GUEST_SERVICE_PID_DIR}`, config.signal);
|
|
1089
1239
|
for (const service of projectedServices) {
|
|
@@ -1135,7 +1285,6 @@ async function resumeVm(config) {
|
|
|
1135
1285
|
config.onDiagnostic?.({
|
|
1136
1286
|
event: "vm.guest_service.not_ready",
|
|
1137
1287
|
level: "warning",
|
|
1138
|
-
credentialMode: guestCredentialMode,
|
|
1139
1288
|
message: `Projected guest service "${service.id}" did not become ready (${service.readiness.path} absent or its process exited); continuing without it`
|
|
1140
1289
|
});
|
|
1141
1290
|
}
|
|
@@ -1144,7 +1293,6 @@ async function resumeVm(config) {
|
|
|
1144
1293
|
if (projectedFiles.length > 0 || projectedServices.length > 0) config.onDiagnostic?.({
|
|
1145
1294
|
event: "vm.guest_projection.applied",
|
|
1146
1295
|
level: "info",
|
|
1147
|
-
credentialMode: guestCredentialMode,
|
|
1148
1296
|
projectedFileCount: projectedFiles.length,
|
|
1149
1297
|
projectedServiceCount: projectedServices.length,
|
|
1150
1298
|
message: `Applied guest projection: ${projectedFiles.length} file(s), ${projectedServices.length} service(s)`
|
|
@@ -1169,66 +1317,5 @@ async function resumeVm(config) {
|
|
|
1169
1317
|
throw err;
|
|
1170
1318
|
}
|
|
1171
1319
|
}
|
|
1172
|
-
/**
|
|
1173
|
-
* Rewrite host-absolute paths inside an agent gitconfig to VM-local
|
|
1174
|
-
* equivalents before injecting it into the guest.
|
|
1175
|
-
*
|
|
1176
|
-
* Two rewrites:
|
|
1177
|
-
* - `signingKey = <host path>` → `<vmSshDir>/id_ed25519`
|
|
1178
|
-
* - `... credential-helper --credentials <host moltnet.json>`
|
|
1179
|
-
* → `<vmAgentDir>/moltnet.json`
|
|
1180
|
-
*
|
|
1181
|
-
* The credential-helper line is generated host-side by `moltnet github setup`
|
|
1182
|
-
* with a host-absolute `--credentials` path; inside the guest that path is
|
|
1183
|
-
* invalid, so it must point at the VM-side moltnet.json. The `insteadOf`
|
|
1184
|
-
* rewrite rule and every other line are workspace-independent and pass through
|
|
1185
|
-
* unchanged. A gitconfig without a credential helper is rewritten only for
|
|
1186
|
-
* `signingKey`.
|
|
1187
|
-
*
|
|
1188
|
-
* This is the single source of truth for git push auth in the guest: the
|
|
1189
|
-
* injected gitconfig carries the tokenless mint-on-demand helper, so the VM
|
|
1190
|
-
* no longer hand-rolls a credential-helper script or runs an imperative
|
|
1191
|
-
* `git config --global ... insteadOf` against the guest $HOME.
|
|
1192
|
-
*/
|
|
1193
|
-
function rewriteGitconfigPaths(gitconfig, vmSshDir, vmAgentDir) {
|
|
1194
|
-
return gitconfig.replace(/signingKey\s*=\s*.+/g, `signingKey = ${vmSshDir}/id_ed25519`).replace(/(moltnet github credential-helper --credentials )\S+/g, `$1${vmAgentDir}/moltnet.json`);
|
|
1195
|
-
}
|
|
1196
|
-
/**
|
|
1197
|
-
* Rewrite host-absolute paths inside moltnet.json to VM-local equivalents.
|
|
1198
|
-
*
|
|
1199
|
-
* Fields rewritten:
|
|
1200
|
-
* ssh.private_key_path → <vmSshDir>/<basename of original>
|
|
1201
|
-
* ssh.public_key_path → <vmSshDir>/<basename of original>
|
|
1202
|
-
* git.config_path → <vmAgentDir>/gitconfig
|
|
1203
|
-
* github.private_key_path → <vmAgentDir>/<pemFilename> (if present)
|
|
1204
|
-
*
|
|
1205
|
-
* All other fields are passed through unchanged.
|
|
1206
|
-
* Throws if moltnetJson is not valid JSON — callers must not inject a broken
|
|
1207
|
-
* moltnet.json into the guest.
|
|
1208
|
-
*/
|
|
1209
|
-
function rewriteMoltnetJsonPaths(moltnetJson, vmAgentDir, vmSshDir, githubAppPemFilename) {
|
|
1210
|
-
const config = JSON.parse(moltnetJson);
|
|
1211
|
-
if (config.ssh && typeof config.ssh === "object") {
|
|
1212
|
-
const ssh = config.ssh;
|
|
1213
|
-
const origPrivate = typeof ssh.private_key_path === "string" ? ssh.private_key_path : null;
|
|
1214
|
-
const origPublic = typeof ssh.public_key_path === "string" ? ssh.public_key_path : null;
|
|
1215
|
-
config.ssh = {
|
|
1216
|
-
...ssh,
|
|
1217
|
-
...origPrivate !== null && { private_key_path: `${vmSshDir}/${path.basename(origPrivate)}` },
|
|
1218
|
-
...origPublic !== null && { public_key_path: `${vmSshDir}/${path.basename(origPublic)}` }
|
|
1219
|
-
};
|
|
1220
|
-
}
|
|
1221
|
-
if (config.git && typeof config.git === "object") {
|
|
1222
|
-
const git = { ...config.git };
|
|
1223
|
-
git.config_path = `${vmAgentDir}/gitconfig`;
|
|
1224
|
-
config.git = git;
|
|
1225
|
-
}
|
|
1226
|
-
if (githubAppPemFilename && config.github && typeof config.github === "object") {
|
|
1227
|
-
const github = { ...config.github };
|
|
1228
|
-
github.private_key_path = `${vmAgentDir}/${githubAppPemFilename}`;
|
|
1229
|
-
config.github = github;
|
|
1230
|
-
}
|
|
1231
|
-
return JSON.stringify(config);
|
|
1232
|
-
}
|
|
1233
1320
|
//#endregion
|
|
1234
|
-
export { AutoParentMemoryProvider, BrokeredHttpSecretBoundaryError, GONDOLIN_BASE_EXECUTABLES, GUEST_SERVICE_PID_DIR, GUEST_TASK_CONTEXT_MOUNT, GUEST_TASK_SKILLS_MOUNT, GuestEnvironmentBoundaryError, abortableResource, activateAgentEnv, assertGuestEnvironmentBoundary, assertHostAuthenticatedGuestEnvironment, canonicalizeBrokeredHttpSecretDescriptor, delay, ensureSnapshot, findMainWorktree, isResolvedPathInsideRoot, loadCredentials, prepareBrokeredHttpSecrets, resolveVfsShadowConfig, resolveVmAgentDir, resumeVm,
|
|
1321
|
+
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.5.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",
|