@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 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: `guest-config` injects the complete
18
- `.moltnet/<agent>` tree; `host-authenticated` withholds it and refuses
19
- `MOLTNET_*` names in runtime-controlled guest environment
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.** A runtime supplies `VmConfig.providerAuth`
31
- (`{ load(): string | null; guestPath }`); the sandbox only carries the blob
32
- across the boundary. Pi's `~/.pi/agent/auth.json` handling lives in
33
- `@themoltnet/pi-runtime` (`piProviderAuth`, and its `resumeVm` wrapper).
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 Prefer assertGuestEnvironmentBoundary for mode-aware checks. */
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(agentDir: string, mode?: GuestCredentialMode, onDiagnostic?: (diagnostic: VmDiagnostic) => void, providerAuth?: ProviderAuthSource): VmCredentials;
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
- export declare interface ProviderAuthSource {
215
- /** Read the auth blob from the host, or return null when absent. */
216
- load(): string | null;
217
- /** Absolute guest path to write the blob to (mode 0600). */
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
- * Provider auth blob supplied by `VmConfig.providerAuth`, or null when the
457
- * runtime supplied none or the host file is absent — the guest then relies
458
- * on env-var providers (`ANTHROPIC_API_KEY`, etc.) carried via `agentEnv`
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.credentials.github_key_missing' | 'vm.http_secrets.bound' | 'vm.host_origins.bound' | 'vm.guest_projection.applied' | 'vm.guest_service.not_ready';
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, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
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 { parseEnv } from "node:util";
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(agentDir, mode = "guest-config", onDiagnostic, providerAuth) {
436
- const moltnetPath = path.join(agentDir, "moltnet.json");
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 = [...new Set(descriptor.hosts.map(normalizeHostPattern))].sort();
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
- if (hosts.length === 0) issues.push(`requirement "${id}" has no destination hosts`);
605
- for (const host of hosts) if (!isValidHostPattern(host)) issues.push(`requirement "${id}" has invalid host pattern "${host}"`);
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(hosts),
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
- if (networkPattern === "*") return true;
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(normalizeHostPattern))];
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
- let url;
710
- try {
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) => hostMatchesPattern(url.hostname, host))) return false;
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) || options.guestCredentialMode === "host-authenticated" && !HOST_AUTHENTICATED_GUEST_ENV_ALLOWLIST.has(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 Prefer assertGuestEnvironmentBoundary for mode-aware checks. */
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
- credentialMode: guestCredentialMode,
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(agentDir, guestCredentialMode, config.onDiagnostic, config.providerAuth);
838
- const configuredApiUrl = creds.moltnetJson ? JSON.parse(creds.moltnetJson).endpoints.api : void 0;
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
- if (guestCredentialMode === "host-authenticated") workspaceProvider = new ShadowProvider(workspaceProvider, {
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, rewriteGitconfigPaths, rewriteMoltnetJsonPaths, shouldRunResumeCommand, shouldShadowNodeModulesPath, throwIfAborted };
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.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.9.1"
50
+ "@earendil-works/gondolin": "^0.12.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.19.0",