@themoltnet/sandbox-gondolin 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ implementation can be added beside it without going through Pi.
20
20
  (`assertGuestEnvironmentBoundary`).
21
21
  - `VmConfig.brokeredSecrets` — host-brokered secrets: the guest sees a
22
22
  placeholder, Gondolin's `SecretManager` substitutes the value only in
23
- requests to the declared host patterns.
23
+ requests to the declared protocol, host patterns, and ports.
24
24
  - `ensureSnapshot` — build and cache the base checkpoint.
25
25
  - Small helpers: `findMainWorktree`, `isResolvedPathInsideRoot`,
26
26
  `abortableResource` / `throwIfAborted`.
@@ -36,11 +36,76 @@ implementation can be added beside it without going through Pi.
36
36
 
37
37
  ## Known boundary facts
38
38
 
39
- - Egress policy is hostname-granular: no scheme or port narrowing.
39
+ - Network egress policy is hostname-granular. Brokered credentials add a
40
+ separately attested protocol and port boundary.
40
41
  - Guest `127.0.0.1` / `localhost` never leave the VM; names are resolved on
41
42
  the host side by the proxy.
42
43
  - Exec abort only drops the host session; a caller that needs the guest
43
44
  process gone must kill it in the guest.
44
45
 
46
+ ## Brokered HTTP secrets
47
+
48
+ `brokeredSecrets` is trusted-host input to `resumeVm`; it is deliberately not
49
+ part of `SandboxConfig`. A remotely stored runtime profile may constrain
50
+ network access and refer to a logical credential requirement, but it must not
51
+ contain a value or choose a host secret-provider coordinate.
52
+
53
+ ```ts
54
+ const managed = await resumeVm({
55
+ checkpointPath,
56
+ agentName: 'worker',
57
+ guestCredentialMode: 'host-authenticated',
58
+ mountPath: workspace,
59
+ sandboxConfig: {
60
+ network: { allowedHosts: ['api.example.com'] },
61
+ },
62
+ brokeredSecrets: [
63
+ {
64
+ id: 'example-api',
65
+ guestEnv: 'EXAMPLE_API_TOKEN',
66
+ hosts: ['api.example.com'],
67
+ value: await trustedHostSecretProvider.get('example-api'),
68
+ },
69
+ ],
70
+ });
71
+ ```
72
+
73
+ Inside the VM, `$EXAMPLE_API_TOKEN` is a random Gondolin placeholder. A normal
74
+ guest command can use it in an HTTP header:
75
+
76
+ ```bash
77
+ curl -fsS \
78
+ -H "Authorization: Bearer $EXAMPLE_API_TOKEN" \
79
+ https://api.example.com/v1/items
80
+ ```
81
+
82
+ The host proxy substitutes the real value only for the declared origin. HTTPS
83
+ on port 443 is the default. Plain HTTP requires an explicit `protocol: 'http'`
84
+ and should be limited to an exact port for a controlled local fixture.
85
+ Preflight fails before VM resume when the binding is missing, an environment
86
+ name collides with another guest source, or a credential host is outside the
87
+ effective network policy. Values are not substituted in request bodies or URL
88
+ queries. Gondolin also decodes, substitutes, and re-encodes HTTP Basic
89
+ authorization, covering the password encoding used by HTTPS Git credential
90
+ helpers; OAuth client secrets sent in form bodies remain host-only.
91
+
92
+ Rotation and revocation do not require exposing or changing the guest
93
+ placeholder:
94
+
95
+ ```ts
96
+ managed.secretManager.rotateSecret('EXAMPLE_API_TOKEN', rotated);
97
+ managed.secretManager.revokeSecret('EXAMPLE_API_TOKEN');
98
+ ```
99
+
100
+ Keep signing keys, GitHub App private keys, SSH keys, and other non-HTTP
101
+ credentials out of this channel. They require narrow host capabilities rather
102
+ than bearer-secret substitution.
103
+
104
+ The portable boundary is the sequence _requirement → trusted local binding →
105
+ resolved delivery_. A Docker sandbox can implement the same sequence with its
106
+ native secret broker; hostname rewriting, placeholder lifecycle, and other
107
+ provider behavior remain adapter-specific, while the attested protocol, host,
108
+ and port boundary must remain equivalent.
109
+
45
110
  Live tests (`vm-manager.integration.test.ts`) boot real VMs and run only with
46
111
  `MOLTNET_PI_VM_INTEGRATION=1`; CI runs them in the agent-daemon Core lane.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { MemoryProvider } from '@earendil-works/gondolin';
2
+ import { SecretDefinition } from '@earendil-works/gondolin';
2
3
  import { VirtualFileHandle } from '@earendil-works/gondolin';
3
4
  import { VM } from '@earendil-works/gondolin';
4
5
 
@@ -38,6 +39,52 @@ export declare class AutoParentMemoryProvider extends MemoryProvider {
38
39
  openSync(pathname: string, flags: string, mode?: number): VirtualFileHandle;
39
40
  }
40
41
 
42
+ /** Per-attempt host binding. The value must never be persisted or evidenced. */
43
+ export declare interface BrokeredHttpSecretBinding extends BrokeredHttpSecretDescriptor {
44
+ /** Resolved only by trusted host code immediately before VM resume. */
45
+ value?: string;
46
+ }
47
+
48
+ export declare class BrokeredHttpSecretBoundaryError extends Error {
49
+ readonly issues: readonly string[];
50
+ constructor(issues: readonly string[]);
51
+ }
52
+
53
+ /**
54
+ * Value-free declaration of one HTTP credential delivered by the Gondolin
55
+ * host proxy. This is an adapter contract, not a persisted runtime-profile
56
+ * schema: trusted host code resolves the matching value for each attempt.
57
+ */
58
+ export declare interface BrokeredHttpSecretDescriptor {
59
+ /** Stable, evidence-safe logical requirement id. */
60
+ id: string;
61
+ /** Guest variable that receives an opaque Gondolin placeholder. */
62
+ guestEnv: string;
63
+ /** Hostname patterns to which the proxy may send the resolved value. */
64
+ hosts: readonly string[];
65
+ /** Attested upstream transport. Defaults to HTTPS. */
66
+ protocol?: 'https' | 'http';
67
+ /** Attested upstream ports. Defaults to 443 for HTTPS and 80 for HTTP. */
68
+ ports?: readonly number[];
69
+ /** Missing values fail preflight unless explicitly optional. */
70
+ required?: boolean;
71
+ }
72
+
73
+ /** Host-only capabilities that cannot widen a validated destination grant. */
74
+ export declare interface BrokeredHttpSecretManager {
75
+ /** Rotate a configured secret value without changing its destination set. */
76
+ rotateSecret(guestEnv: string, value: string): void;
77
+ /** Revoke a configured secret for the remainder of this VM lifetime. */
78
+ revokeSecret(guestEnv: string): void;
79
+ }
80
+
81
+ /**
82
+ * Canonicalize and validate the value-free portion of a brokered binding.
83
+ * Runtime attestation and Gondolin enforcement both consume this function so
84
+ * the evidenced destination set cannot drift from the enforced one.
85
+ */
86
+ export declare function canonicalizeBrokeredHttpSecretDescriptor(descriptor: BrokeredHttpSecretDescriptor): Required<BrokeredHttpSecretDescriptor>;
87
+
41
88
  export declare function delay(ms: number, signal: AbortSignal | undefined, label: string): Promise<void>;
42
89
 
43
90
  /**
@@ -62,6 +109,9 @@ export declare function findMainWorktree(startPath?: string): string;
62
109
  /** Commands guaranteed by the base Gondolin snapshot. */
63
110
  export declare const GONDOLIN_BASE_EXECUTABLES: readonly string[];
64
111
 
112
+ /** Guest directory holding one PID file per projected service. */
113
+ export declare const GUEST_SERVICE_PID_DIR = "/run/moltnet/services";
114
+
65
115
  /**
66
116
  * Memory-backed VFS mount used by the daemon to inject task context
67
117
  * (#943 slice 1.5). This is a separate top-level mount because Gondolin
@@ -94,6 +144,39 @@ export declare class GuestEnvironmentBoundaryError extends Error {
94
144
  constructor(refusedNames: readonly string[]);
95
145
  }
96
146
 
147
+ /** Declarative, trusted-host guest projection (env, files, services). */
148
+ export declare interface GuestProjectionInput {
149
+ env?: Record<string, string>;
150
+ files?: {
151
+ path: string;
152
+ content: string | Uint8Array;
153
+ mode?: number;
154
+ }[];
155
+ services?: {
156
+ id: string;
157
+ command: readonly string[];
158
+ env?: Record<string, string>;
159
+ /** Guest path to wait for before the session starts. */
160
+ readiness?: {
161
+ path: string;
162
+ timeoutMs?: number;
163
+ required?: boolean;
164
+ };
165
+ }[];
166
+ }
167
+
168
+ /** Lifecycle handle for projected guest services. */
169
+ export declare interface GuestServices {
170
+ stop(): Promise<void>;
171
+ }
172
+
173
+ /**
174
+ * Host origins: hostnames the guest can reach whose responses are produced by
175
+ * host code inside the proxy (`onRequest` short-circuit). Nothing is listened
176
+ * on, nothing is forwarded; the sandbox knows only the origin → handler map.
177
+ */
178
+ declare type HostOriginHandler = (request: Request) => Promise<Response>;
179
+
97
180
  /**
98
181
  * Check containment for already-resolved lexical or real paths.
99
182
  *
@@ -109,11 +192,25 @@ export declare function loadCredentials(agentDir: string, mode?: GuestCredential
109
192
  export declare interface ManagedVm {
110
193
  vm: VM;
111
194
  credentials: VmCredentials;
195
+ /** Host-only rotation and revocation handle. It cannot widen destinations. */
196
+ secretManager: BrokeredHttpSecretManager;
197
+ /** Projected guest services; `stop()` before closing the VM. Always present. */
198
+ services: GuestServices;
112
199
  mountPath: string;
113
200
  guestWorkspace: string;
114
201
  agentDir: string;
115
202
  }
116
203
 
204
+ /**
205
+ * Validate value-free descriptors, host-local bindings, environment
206
+ * collisions, and destination coverage before Gondolin creates any VM.
207
+ */
208
+ export declare function prepareBrokeredHttpSecrets(options: {
209
+ bindings?: readonly BrokeredHttpSecretBinding[];
210
+ allowedHosts: readonly string[];
211
+ occupiedGuestEnvNames?: readonly string[];
212
+ }): Record<string, SecretDefinition>;
213
+
117
214
  export declare interface ProviderAuthSource {
118
215
  /** Read the auth blob from the host, or return null when absent. */
119
216
  load(): string | null;
@@ -318,6 +415,25 @@ export declare interface VmConfig {
318
415
  * into the guest without storing secret values in the profile.
319
416
  */
320
417
  forwardEnv?: string[];
418
+ /**
419
+ * Trusted-host, per-attempt HTTP secret bindings. These are deliberately
420
+ * outside SandboxConfig so remotely stored runtime profiles cannot carry
421
+ * raw values or select host secret-provider coordinates.
422
+ */
423
+ brokeredSecrets?: readonly BrokeredHttpSecretBinding[];
424
+ /**
425
+ * Trusted-host origins answered in-process by the proxy. Keys are full
426
+ * origins (`https://name.moltnet.internal`). Like brokered secrets these are
427
+ * outside SandboxConfig: a remotely stored profile cannot register one.
428
+ */
429
+ hostOrigins?: Record<string, HostOriginHandler>;
430
+ /**
431
+ * Trusted-host guest projection: env merged last (after broker
432
+ * placeholders), files written before the session starts, services run in
433
+ * the guest for the session's lifetime. Not subject to the profile env
434
+ * reserved-name guard — the runtime, not the profile, declares it.
435
+ */
436
+ guestProjection?: GuestProjectionInput;
321
437
  /** Structured credential-boundary diagnostics for daemon loggers. */
322
438
  onDiagnostic?: (diagnostic: VmDiagnostic) => void;
323
439
  /** Abort resume/setup work, closing any live VM owned by resumeVm. */
@@ -355,10 +471,17 @@ export declare interface VmCredentials {
355
471
  }
356
472
 
357
473
  export declare interface VmDiagnostic {
358
- event: 'vm.credentials.mode' | 'vm.credentials.github_key_missing';
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';
359
475
  level: 'info' | 'warning';
360
476
  message: string;
361
477
  credentialMode: GuestCredentialMode;
478
+ /** Present only for the value-free broker summary event. */
479
+ brokeredSecretCount?: number;
480
+ /** Present only for the host-origins summary event. */
481
+ hostOriginCount?: number;
482
+ /** Present only for the guest-projection summary event. */
483
+ projectedFileCount?: number;
484
+ projectedServiceCount?: number;
362
485
  }
363
486
 
364
487
  export { }
package/dist/index.js CHANGED
@@ -100,6 +100,7 @@ var BASE_ALPINE_PACKAGE_EXECUTABLES = {
100
100
  file: "file",
101
101
  git: "git",
102
102
  jq: "jq",
103
+ "openssh-keygen": "ssh-keygen",
103
104
  ripgrep: "rg",
104
105
  tar: "tar",
105
106
  xz: "xz"
@@ -292,6 +293,35 @@ function pruneOldSnapshots(maxCached, currentDir) {
292
293
  });
293
294
  }
294
295
  //#endregion
296
+ //#region src/host-origins.ts
297
+ function hostOriginHostnames(origins) {
298
+ return Object.keys(origins).map((origin) => new URL(origin).hostname).sort();
299
+ }
300
+ function blocked() {
301
+ return new Response(JSON.stringify({
302
+ code: "host_origin_blocked",
303
+ message: "origin not served"
304
+ }), {
305
+ status: 421,
306
+ headers: { "content-type": "application/json" }
307
+ });
308
+ }
309
+ /**
310
+ * Serve a registered origin in-process. Fails closed for any request whose
311
+ * hostname belongs to a virtual origin but whose exact origin (scheme, host,
312
+ * port) is not registered: such a request must never fall through to normal
313
+ * outbound proxy forwarding (an SSRF route to a host-controlled name).
314
+ */
315
+ function createHostOriginsOnRequest(origins) {
316
+ const virtualHostnames = new Set(hostOriginHostnames(origins));
317
+ return async (request) => {
318
+ const url = new URL(request.url);
319
+ const handler = origins[url.origin];
320
+ if (handler) return handler(request);
321
+ if (virtualHostnames.has(url.hostname)) return blocked();
322
+ };
323
+ }
324
+ //#endregion
295
325
  //#region src/vm-manager.ts
296
326
  /**
297
327
  * Memory-backed VFS mount used by the daemon to inject task context
@@ -314,6 +344,12 @@ function pruneOldSnapshots(maxCached, currentDir) {
314
344
  * investigation and the alternatives we rejected.
315
345
  */
316
346
  var GUEST_TASK_CONTEXT_MOUNT = "/moltnet-task-context";
347
+ /** Guest directory holding one PID file per projected service. */
348
+ var GUEST_SERVICE_PID_DIR = "/run/moltnet/services";
349
+ var GUEST_SERVICE_ID_RE = /^[a-z][a-z0-9-]{0,62}$/;
350
+ function assertGuestServiceId(id) {
351
+ if (!GUEST_SERVICE_ID_RE.test(id)) throw new Error(`Invalid guest service id "${id}": expected ${GUEST_SERVICE_ID_RE}`);
352
+ }
317
353
  /** @deprecated Use GUEST_TASK_CONTEXT_MOUNT. */
318
354
  var GUEST_TASK_SKILLS_MOUNT = GUEST_TASK_CONTEXT_MOUNT;
319
355
  function resolveVfsShadowConfig(config) {
@@ -529,6 +565,174 @@ var GuestEnvironmentBoundaryError = class extends Error {
529
565
  this.name = "GuestEnvironmentBoundaryError";
530
566
  }
531
567
  };
568
+ var BrokeredHttpSecretBoundaryError = class extends Error {
569
+ constructor(issues) {
570
+ super("Brokered HTTP secret boundary refused the resolved bindings: " + issues.join("; "));
571
+ this.issues = issues;
572
+ this.name = "BrokeredHttpSecretBoundaryError";
573
+ }
574
+ };
575
+ var BROKERED_SECRET_ID_REGEXP = /^[a-z][a-z0-9._-]{0,63}$/;
576
+ var GUEST_ENV_NAME_REGEXP = /^[A-Za-z_][A-Za-z0-9_]*$/;
577
+ var OBJECT_META_PROPERTY_NAMES = new Set([
578
+ "__proto__",
579
+ "constructor",
580
+ "prototype"
581
+ ]);
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
+ /**
589
+ * Canonicalize and validate the value-free portion of a brokered binding.
590
+ * Runtime attestation and Gondolin enforcement both consume this function so
591
+ * the evidenced destination set cannot drift from the enforced one.
592
+ */
593
+ function canonicalizeBrokeredHttpSecretDescriptor(descriptor) {
594
+ const issues = [];
595
+ const id = descriptor.id.trim();
596
+ const guestEnv = descriptor.guestEnv.trim();
597
+ const hosts = [...new Set(descriptor.hosts.map(normalizeHostPattern))].sort();
598
+ const protocolInput = descriptor.protocol ?? "https";
599
+ const protocol = protocolInput === "http" ? "http" : "https";
600
+ const ports = [...new Set(descriptor.ports ?? [protocol === "https" ? 443 : 80])].sort((left, right) => left - right);
601
+ if (!BROKERED_SECRET_ID_REGEXP.test(id)) issues.push(`invalid requirement id "${id || "<empty>"}"`);
602
+ if (!GUEST_ENV_NAME_REGEXP.test(guestEnv)) issues.push(`requirement "${id}" has invalid guest env "${guestEnv}"`);
603
+ 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}"`);
606
+ if (protocolInput !== "https" && protocolInput !== "http") issues.push(`requirement "${id}" has invalid protocol "${String(protocolInput)}"`);
607
+ if (ports.length === 0) issues.push(`requirement "${id}" has no destination ports`);
608
+ for (const port of ports) if (!Number.isInteger(port) || port < 1 || port > 65535) issues.push(`requirement "${id}" has invalid port "${port}"`);
609
+ if (issues.length > 0) throw new BrokeredHttpSecretBoundaryError(issues);
610
+ return Object.freeze({
611
+ id,
612
+ guestEnv,
613
+ hosts: Object.freeze(hosts),
614
+ protocol,
615
+ ports: Object.freeze(ports),
616
+ required: descriptor.required !== false
617
+ });
618
+ }
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
+ /**
624
+ * Conservative subset check for Gondolin hostname globs. Exact secret hosts
625
+ * may sit below a broader network wildcard. A wildcard secret destination must
626
+ * be repeated exactly (or covered by `*`) so a credential grant cannot widen
627
+ * through an ambiguous glob comparison.
628
+ */
629
+ function networkPatternCoversSecretPattern(networkPattern, secretPattern) {
630
+ if (networkPattern === "*") return true;
631
+ if (secretPattern.includes("*")) return networkPattern === secretPattern;
632
+ return hostMatchesPattern(secretPattern, networkPattern);
633
+ }
634
+ /**
635
+ * Validate value-free descriptors, host-local bindings, environment
636
+ * collisions, and destination coverage before Gondolin creates any VM.
637
+ */
638
+ function prepareBrokeredHttpSecrets(options) {
639
+ const issues = [];
640
+ const ids = /* @__PURE__ */ new Set();
641
+ const guestEnvNames = /* @__PURE__ */ new Set();
642
+ const occupiedGuestEnvNames = new Set(options.occupiedGuestEnvNames ?? []);
643
+ const allowedHosts = [...new Set(options.allowedHosts.map(normalizeHostPattern))];
644
+ const secrets = Object.create(null);
645
+ for (const binding of options.bindings ?? []) {
646
+ let descriptor;
647
+ try {
648
+ descriptor = canonicalizeBrokeredHttpSecretDescriptor(binding);
649
+ } catch (error) {
650
+ if (error instanceof BrokeredHttpSecretBoundaryError) {
651
+ issues.push(...error.issues);
652
+ continue;
653
+ }
654
+ throw error;
655
+ }
656
+ const { id, guestEnv, hosts } = descriptor;
657
+ if (ids.has(id)) issues.push(`duplicate requirement id "${id}"`);
658
+ ids.add(id);
659
+ if (guestEnvNames.has(guestEnv)) issues.push(`duplicate guest env "${guestEnv}"`);
660
+ else if (occupiedGuestEnvNames.has(guestEnv)) issues.push(`guest env "${guestEnv}" is already supplied by another source`);
661
+ guestEnvNames.add(guestEnv);
662
+ for (const host of hosts) if (!allowedHosts.some((allowedHost) => networkPatternCoversSecretPattern(allowedHost, host))) issues.push(`requirement "${id}" host "${host}" is outside the effective network policy`);
663
+ const value = binding.value;
664
+ if (value === void 0 || value === "") {
665
+ if (descriptor.required) issues.push(`required binding "${id}" has no resolved value`);
666
+ continue;
667
+ }
668
+ secrets[guestEnv] = {
669
+ hosts: [...hosts],
670
+ value
671
+ };
672
+ }
673
+ if (issues.length > 0) throw new BrokeredHttpSecretBoundaryError(issues);
674
+ return secrets;
675
+ }
676
+ function decodeBasicAuthorization(value) {
677
+ const match = /^Basic\s+([^\s]+)$/i.exec(value);
678
+ if (!match) return void 0;
679
+ try {
680
+ return Buffer.from(match[1], "base64").toString("utf8");
681
+ } catch {
682
+ return;
683
+ }
684
+ }
685
+ function headersContainSecretValue(headers, value) {
686
+ if (value === "") return false;
687
+ for (const [name, headerValue] of headers.entries()) {
688
+ if (headerValue.includes(value)) return true;
689
+ if (/^(authorization|proxy-authorization)$/i.test(name) && decodeBasicAuthorization(headerValue)?.includes(value)) return true;
690
+ }
691
+ return false;
692
+ }
693
+ function createBrokeredHttpSecretOriginPolicy(bindings) {
694
+ const entries = /* @__PURE__ */ new Map();
695
+ for (const binding of bindings) {
696
+ if (binding.value === void 0 || binding.value === "") continue;
697
+ const descriptor = canonicalizeBrokeredHttpSecretDescriptor(binding);
698
+ entries.set(descriptor.guestEnv, {
699
+ guestEnv: descriptor.guestEnv,
700
+ hosts: descriptor.hosts,
701
+ protocol: descriptor.protocol,
702
+ ports: descriptor.ports,
703
+ value: binding.value,
704
+ deleted: false
705
+ });
706
+ }
707
+ return {
708
+ 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;
717
+ for (const entry of entries.values()) {
718
+ 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;
720
+ }
721
+ return true;
722
+ },
723
+ rotateSecret(guestEnv, value) {
724
+ const entry = entries.get(guestEnv);
725
+ if (!entry) throw new Error(`unknown brokered secret: ${guestEnv}`);
726
+ if (entry.deleted) throw new Error(`brokered secret revoked: ${guestEnv}`);
727
+ entry.value = value;
728
+ },
729
+ revokeSecret(guestEnv) {
730
+ const entry = entries.get(guestEnv);
731
+ if (!entry) throw new Error(`unknown brokered secret: ${guestEnv}`);
732
+ entry.deleted = true;
733
+ }
734
+ };
735
+ }
532
736
  function assertGuestEnvironmentBoundary(options) {
533
737
  const refusedForwardEnv = (options.forwardEnv ?? []).filter((name) => isReservedGuestEnvironmentName(name) || options.guestCredentialMode === "host-authenticated" && !HOST_AUTHENTICATED_GUEST_ENV_ALLOWLIST.has(name));
534
738
  const refusedSandboxEnv = Object.keys(options.sandboxEnv ?? {}).filter(isReservedGuestEnvironmentName);
@@ -584,6 +788,10 @@ function assertInternalHostsDoNotOverlapProtectedHosts(internalHosts, protectedH
584
788
  * surface immediately rather than fall through to cryptic agent
585
789
  * errors later.
586
790
  */
791
+ /** Single-quote a POSIX shell argument (paths/ids are pre-validated). */
792
+ function shellQuote(value) {
793
+ return `'${value.replaceAll("'", `'\\''`)}'`;
794
+ }
587
795
  async function vmRun(vm, label, command, signal) {
588
796
  const wrapped = `set -eu\nset -o pipefail\n${command}`;
589
797
  throwIfAborted(signal, `resume step "${label}"`);
@@ -637,9 +845,61 @@ async function resumeVm(config) {
637
845
  ...config.extraAllowedHosts ?? []
638
846
  ])];
639
847
  assertInternalHostsDoNotOverlapProtectedHosts(runtimeAllowedInternalHosts, protectedExternalHosts);
640
- const { httpHooks, env: secretEnv } = createHttpHooks({
641
- allowedHosts: [...new Set([...protectedExternalHosts, ...runtimeAllowedHosts])],
642
- allowedInternalHosts: runtimeAllowedInternalHosts
848
+ const allowedHosts = [...new Set([...protectedExternalHosts, ...runtimeAllowedHosts])];
849
+ const hostOrigins = config.hostOrigins ?? {};
850
+ const hostOriginHosts = hostOriginHostnames(hostOrigins);
851
+ assertInternalHostsDoNotOverlapProtectedHosts(hostOriginHosts, protectedExternalHosts);
852
+ const allowedInternalHosts = [...new Set([...runtimeAllowedInternalHosts, ...hostOriginHosts])];
853
+ const projectedEnv = config.guestProjection?.env ?? {};
854
+ const brokeredSecrets = prepareBrokeredHttpSecrets({
855
+ bindings: config.brokeredSecrets,
856
+ allowedHosts: [...allowedHosts, ...allowedInternalHosts],
857
+ occupiedGuestEnvNames: [
858
+ "PATH",
859
+ "HOME",
860
+ "NODE_NO_WARNINGS",
861
+ "NODE_EXTRA_CA_CERTS",
862
+ "MOLTNET_GUEST_WORKSPACE",
863
+ ...config.forwardEnv ?? [],
864
+ ...Object.keys(creds.agentEnv),
865
+ ...Object.keys(config.sandboxConfig?.env ?? {}),
866
+ ...Object.keys(projectedEnv)
867
+ ]
868
+ });
869
+ const brokeredSecretOriginPolicy = createBrokeredHttpSecretOriginPolicy(config.brokeredSecrets ?? []);
870
+ const { httpHooks, env: secretEnv, secretManager: gondolinSecretManager } = createHttpHooks({
871
+ allowedHosts,
872
+ allowedInternalHosts,
873
+ ...Object.keys(brokeredSecrets).length > 0 && {
874
+ secrets: brokeredSecrets,
875
+ isRequestAllowed: brokeredSecretOriginPolicy.isRequestAllowed
876
+ },
877
+ ...hostOriginHosts.length > 0 && { onRequest: createHostOriginsOnRequest(hostOrigins) }
878
+ });
879
+ const secretManager = Object.freeze({
880
+ rotateSecret(guestEnv, value) {
881
+ gondolinSecretManager.updateSecret(guestEnv, { value });
882
+ brokeredSecretOriginPolicy.rotateSecret(guestEnv, value);
883
+ },
884
+ revokeSecret(guestEnv) {
885
+ gondolinSecretManager.deleteSecret(guestEnv);
886
+ brokeredSecretOriginPolicy.revokeSecret(guestEnv);
887
+ }
888
+ });
889
+ const brokeredSecretCount = Object.keys(brokeredSecrets).length;
890
+ if (brokeredSecretCount > 0) config.onDiagnostic?.({
891
+ event: "vm.http_secrets.bound",
892
+ level: "info",
893
+ credentialMode: guestCredentialMode,
894
+ brokeredSecretCount,
895
+ message: `Bound ${brokeredSecretCount} HTTP secret placeholder${brokeredSecretCount === 1 ? "" : "s"} to the host proxy`
896
+ });
897
+ if (hostOriginHosts.length > 0) config.onDiagnostic?.({
898
+ event: "vm.host_origins.bound",
899
+ level: "info",
900
+ credentialMode: guestCredentialMode,
901
+ hostOriginCount: hostOriginHosts.length,
902
+ message: `Bound ${hostOriginHosts.length} host origin${hostOriginHosts.length === 1 ? "" : "s"} to the host proxy`
643
903
  });
644
904
  const vmAgentDir = `/home/agent/.moltnet/${config.agentName}`;
645
905
  const vmAgentEnv = {};
@@ -678,7 +938,6 @@ async function resumeVm(config) {
678
938
  }
679
939
  const envOverrides = config.sandboxConfig?.env ?? {};
680
940
  const vmEnv = {
681
- ...secretEnv,
682
941
  ...vmAgentEnv,
683
942
  ...forwardedEnv,
684
943
  PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/go/bin",
@@ -686,7 +945,9 @@ async function resumeVm(config) {
686
945
  NODE_NO_WARNINGS: "1",
687
946
  NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt",
688
947
  ...envOverrides,
689
- MOLTNET_GUEST_WORKSPACE: guestWorkspace
948
+ MOLTNET_GUEST_WORKSPACE: guestWorkspace,
949
+ ...secretEnv,
950
+ ...projectedEnv
690
951
  };
691
952
  const resources = config.sandboxConfig?.resources;
692
953
  const workspaceMode = config.workspaceMode ?? "shared_mount";
@@ -709,6 +970,27 @@ async function resumeVm(config) {
709
970
  process.stderr.write(`[vm] aborted resume late vm.close() failed: ${message}\n`);
710
971
  }
711
972
  });
973
+ const servicesAbort = new AbortController();
974
+ const serviceHandles = [];
975
+ const serviceIds = [];
976
+ const services = { async stop() {
977
+ if (serviceIds.length > 0) {
978
+ const pidFiles = serviceIds.map((id) => `${GUEST_SERVICE_PID_DIR}/${id}.pid`).join(" ");
979
+ try {
980
+ await vm.exec([
981
+ "sh",
982
+ "-c",
983
+ `for f in ${pidFiles}; do [ -f "$f" ] || continue; pid=$(cat "$f"); kill -TERM -- "-\$pid" 2>/dev/null || kill -TERM "\$pid" 2>/dev/null || true; done; sleep 0.2; for f in ${pidFiles}; do [ -f "$f" ] || continue; pid=$(cat "$f"); kill -KILL -- "-\$pid" 2>/dev/null || kill -KILL "\$pid" 2>/dev/null || true; rm -f "\$f"; done`
984
+ ], {
985
+ stdout: "ignore",
986
+ stderr: "ignore"
987
+ });
988
+ } catch {}
989
+ serviceIds.length = 0;
990
+ }
991
+ servicesAbort.abort();
992
+ await Promise.allSettled(serviceHandles);
993
+ } };
712
994
  try {
713
995
  await vmRun(vm, "TLS certificates", `
714
996
  cp /etc/gondolin/mitm/ca.crt /usr/local/share/ca-certificates/gondolin-mitm.crt
@@ -744,8 +1026,14 @@ async function resumeVm(config) {
744
1026
  const vmSshDir = `${vmAgentDir}/ssh`;
745
1027
  const hasAgentFiles = guestCredentialMode === "guest-config";
746
1028
  const providerAuthDir = config.providerAuth ? path.posix.dirname(config.providerAuth.guestPath) : null;
747
- const guestDirs = [...hasAgentFiles ? [`${vmAgentDir}/ssh`] : [], ...providerAuthDir ? [providerAuthDir] : []];
748
- if (guestDirs.length > 0) await vm.exec(`mkdir -p ${guestDirs.join(" ")}`, { signal: config.signal });
1029
+ const projectedFiles = config.guestProjection?.files ?? [];
1030
+ 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
+ ];
1036
+ if (guestDirs.length > 0) await vmRun(vm, "create guest directories", `mkdir -p ${guestDirs.map(shellQuote).join(" ")}`, config.signal);
749
1037
  if (creds.providerAuthJson !== null && config.providerAuth) await vm.fs.writeFile(config.providerAuth.guestPath, creds.providerAuthJson, {
750
1038
  mode: 384,
751
1039
  signal: config.signal
@@ -784,15 +1072,94 @@ async function resumeVm(config) {
784
1072
  signal: config.signal
785
1073
  });
786
1074
  }
787
- await vm.exec(hasAgentFiles ? "chown -R agent:agent /home/agent/.pi /home/agent/.moltnet" : "chown -R agent:agent /home/agent/.pi", { signal: config.signal });
1075
+ for (const file of projectedFiles) {
1076
+ await vm.fs.writeFile(file.path, file.content, {
1077
+ mode: file.mode ?? 420,
1078
+ signal: config.signal
1079
+ });
1080
+ if (file.mode !== void 0) await vmRun(vm, `chmod projected file ${file.path}`, `chmod ${file.mode.toString(8)} ${shellQuote(file.path)}`, config.signal);
1081
+ }
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);
1087
+ const projectedServices = config.guestProjection?.services ?? [];
1088
+ if (projectedServices.length > 0) await vmRun(vm, "create service pid directory", `mkdir -p ${GUEST_SERVICE_PID_DIR}`, config.signal);
1089
+ for (const service of projectedServices) {
1090
+ assertGuestServiceId(service.id);
1091
+ const handle = vm.exec([
1092
+ "setsid",
1093
+ "sh",
1094
+ "-c",
1095
+ `echo $$ > ${GUEST_SERVICE_PID_DIR}/${service.id}.pid && exec "$@"`,
1096
+ "moltnet-service",
1097
+ ...service.command
1098
+ ], {
1099
+ stdout: "ignore",
1100
+ stderr: "ignore",
1101
+ ...service.env && { env: service.env },
1102
+ signal: servicesAbort.signal
1103
+ });
1104
+ serviceHandles.push(Promise.resolve(handle).catch(() => void 0));
1105
+ serviceIds.push(service.id);
1106
+ }
1107
+ async function awaitServiceReady(service) {
1108
+ if (!service.readiness) return;
1109
+ const deadline = Date.now() + (service.readiness.timeoutMs ?? 1e4);
1110
+ const pidFile = `${GUEST_SERVICE_PID_DIR}/${service.id}.pid`;
1111
+ let ready = false;
1112
+ while (Date.now() < deadline) {
1113
+ throwIfAborted(config.signal, `service "${service.id}" readiness`);
1114
+ if ((await vm.exec([
1115
+ "sh",
1116
+ "-c",
1117
+ "[ -e \"$1\" ] && [ -r \"$2\" ] && kill -0 \"$(cat \"$2\" 2>/dev/null)\" 2>/dev/null",
1118
+ "moltnet-readiness",
1119
+ service.readiness.path,
1120
+ pidFile
1121
+ ], {
1122
+ stdout: "ignore",
1123
+ stderr: "ignore",
1124
+ signal: config.signal
1125
+ })).exitCode === 0) {
1126
+ ready = true;
1127
+ break;
1128
+ }
1129
+ await new Promise((resolve) => {
1130
+ setTimeout(resolve, 200);
1131
+ });
1132
+ }
1133
+ if (!ready) {
1134
+ if (service.readiness.required) throw new Error(`Projected guest service "${service.id}" did not become ready: ${service.readiness.path} absent or its process exited`);
1135
+ config.onDiagnostic?.({
1136
+ event: "vm.guest_service.not_ready",
1137
+ level: "warning",
1138
+ credentialMode: guestCredentialMode,
1139
+ message: `Projected guest service "${service.id}" did not become ready (${service.readiness.path} absent or its process exited); continuing without it`
1140
+ });
1141
+ }
1142
+ }
1143
+ await Promise.all(projectedServices.map((service) => awaitServiceReady(service)));
1144
+ if (projectedFiles.length > 0 || projectedServices.length > 0) config.onDiagnostic?.({
1145
+ event: "vm.guest_projection.applied",
1146
+ level: "info",
1147
+ credentialMode: guestCredentialMode,
1148
+ projectedFileCount: projectedFiles.length,
1149
+ projectedServiceCount: projectedServices.length,
1150
+ message: `Applied guest projection: ${projectedFiles.length} file(s), ${projectedServices.length} service(s)`
1151
+ });
788
1152
  return {
789
1153
  vm,
790
1154
  credentials: creds,
1155
+ secretManager,
1156
+ services,
791
1157
  mountPath: config.mountPath,
792
1158
  guestWorkspace,
793
1159
  agentDir
794
1160
  };
795
1161
  } catch (err) {
1162
+ servicesAbort.abort();
796
1163
  try {
797
1164
  await vm.close();
798
1165
  } catch (closeErr) {
@@ -864,4 +1231,4 @@ function rewriteMoltnetJsonPaths(moltnetJson, vmAgentDir, vmSshDir, githubAppPem
864
1231
  return JSON.stringify(config);
865
1232
  }
866
1233
  //#endregion
867
- export { AutoParentMemoryProvider, GONDOLIN_BASE_EXECUTABLES, GUEST_TASK_CONTEXT_MOUNT, GUEST_TASK_SKILLS_MOUNT, GuestEnvironmentBoundaryError, abortableResource, activateAgentEnv, assertGuestEnvironmentBoundary, assertHostAuthenticatedGuestEnvironment, delay, ensureSnapshot, findMainWorktree, isResolvedPathInsideRoot, loadCredentials, resolveVfsShadowConfig, resolveVmAgentDir, resumeVm, rewriteGitconfigPaths, rewriteMoltnetJsonPaths, shouldRunResumeCommand, shouldShadowNodeModulesPath, throwIfAborted };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/sandbox-gondolin",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",