@themoltnet/sandbox-gondolin 0.2.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/dist/index.d.ts CHANGED
@@ -109,6 +109,9 @@ export declare function findMainWorktree(startPath?: string): string;
109
109
  /** Commands guaranteed by the base Gondolin snapshot. */
110
110
  export declare const GONDOLIN_BASE_EXECUTABLES: readonly string[];
111
111
 
112
+ /** Guest directory holding one PID file per projected service. */
113
+ export declare const GUEST_SERVICE_PID_DIR = "/run/moltnet/services";
114
+
112
115
  /**
113
116
  * Memory-backed VFS mount used by the daemon to inject task context
114
117
  * (#943 slice 1.5). This is a separate top-level mount because Gondolin
@@ -141,6 +144,39 @@ export declare class GuestEnvironmentBoundaryError extends Error {
141
144
  constructor(refusedNames: readonly string[]);
142
145
  }
143
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
+
144
180
  /**
145
181
  * Check containment for already-resolved lexical or real paths.
146
182
  *
@@ -158,6 +194,8 @@ export declare interface ManagedVm {
158
194
  credentials: VmCredentials;
159
195
  /** Host-only rotation and revocation handle. It cannot widen destinations. */
160
196
  secretManager: BrokeredHttpSecretManager;
197
+ /** Projected guest services; `stop()` before closing the VM. Always present. */
198
+ services: GuestServices;
161
199
  mountPath: string;
162
200
  guestWorkspace: string;
163
201
  agentDir: string;
@@ -383,6 +421,19 @@ export declare interface VmConfig {
383
421
  * raw values or select host secret-provider coordinates.
384
422
  */
385
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;
386
437
  /** Structured credential-boundary diagnostics for daemon loggers. */
387
438
  onDiagnostic?: (diagnostic: VmDiagnostic) => void;
388
439
  /** Abort resume/setup work, closing any live VM owned by resumeVm. */
@@ -420,12 +471,17 @@ export declare interface VmCredentials {
420
471
  }
421
472
 
422
473
  export declare interface VmDiagnostic {
423
- event: 'vm.credentials.mode' | 'vm.credentials.github_key_missing' | 'vm.http_secrets.bound';
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';
424
475
  level: 'info' | 'warning';
425
476
  message: string;
426
477
  credentialMode: GuestCredentialMode;
427
478
  /** Present only for the value-free broker summary event. */
428
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;
429
485
  }
430
486
 
431
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) {
@@ -752,6 +788,10 @@ function assertInternalHostsDoNotOverlapProtectedHosts(internalHosts, protectedH
752
788
  * surface immediately rather than fall through to cryptic agent
753
789
  * errors later.
754
790
  */
791
+ /** Single-quote a POSIX shell argument (paths/ids are pre-validated). */
792
+ function shellQuote(value) {
793
+ return `'${value.replaceAll("'", `'\\''`)}'`;
794
+ }
755
795
  async function vmRun(vm, label, command, signal) {
756
796
  const wrapped = `set -eu\nset -o pipefail\n${command}`;
757
797
  throwIfAborted(signal, `resume step "${label}"`);
@@ -806,9 +846,14 @@ async function resumeVm(config) {
806
846
  ])];
807
847
  assertInternalHostsDoNotOverlapProtectedHosts(runtimeAllowedInternalHosts, protectedExternalHosts);
808
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 ?? {};
809
854
  const brokeredSecrets = prepareBrokeredHttpSecrets({
810
855
  bindings: config.brokeredSecrets,
811
- allowedHosts: [...allowedHosts, ...runtimeAllowedInternalHosts],
856
+ allowedHosts: [...allowedHosts, ...allowedInternalHosts],
812
857
  occupiedGuestEnvNames: [
813
858
  "PATH",
814
859
  "HOME",
@@ -817,17 +862,19 @@ async function resumeVm(config) {
817
862
  "MOLTNET_GUEST_WORKSPACE",
818
863
  ...config.forwardEnv ?? [],
819
864
  ...Object.keys(creds.agentEnv),
820
- ...Object.keys(config.sandboxConfig?.env ?? {})
865
+ ...Object.keys(config.sandboxConfig?.env ?? {}),
866
+ ...Object.keys(projectedEnv)
821
867
  ]
822
868
  });
823
869
  const brokeredSecretOriginPolicy = createBrokeredHttpSecretOriginPolicy(config.brokeredSecrets ?? []);
824
870
  const { httpHooks, env: secretEnv, secretManager: gondolinSecretManager } = createHttpHooks({
825
871
  allowedHosts,
826
- allowedInternalHosts: runtimeAllowedInternalHosts,
872
+ allowedInternalHosts,
827
873
  ...Object.keys(brokeredSecrets).length > 0 && {
828
874
  secrets: brokeredSecrets,
829
875
  isRequestAllowed: brokeredSecretOriginPolicy.isRequestAllowed
830
- }
876
+ },
877
+ ...hostOriginHosts.length > 0 && { onRequest: createHostOriginsOnRequest(hostOrigins) }
831
878
  });
832
879
  const secretManager = Object.freeze({
833
880
  rotateSecret(guestEnv, value) {
@@ -847,6 +894,13 @@ async function resumeVm(config) {
847
894
  brokeredSecretCount,
848
895
  message: `Bound ${brokeredSecretCount} HTTP secret placeholder${brokeredSecretCount === 1 ? "" : "s"} to the host proxy`
849
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`
903
+ });
850
904
  const vmAgentDir = `/home/agent/.moltnet/${config.agentName}`;
851
905
  const vmAgentEnv = {};
852
906
  for (const [k, v] of Object.entries(creds.agentEnv)) {
@@ -892,7 +946,8 @@ async function resumeVm(config) {
892
946
  NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt",
893
947
  ...envOverrides,
894
948
  MOLTNET_GUEST_WORKSPACE: guestWorkspace,
895
- ...secretEnv
949
+ ...secretEnv,
950
+ ...projectedEnv
896
951
  };
897
952
  const resources = config.sandboxConfig?.resources;
898
953
  const workspaceMode = config.workspaceMode ?? "shared_mount";
@@ -915,6 +970,27 @@ async function resumeVm(config) {
915
970
  process.stderr.write(`[vm] aborted resume late vm.close() failed: ${message}\n`);
916
971
  }
917
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
+ } };
918
994
  try {
919
995
  await vmRun(vm, "TLS certificates", `
920
996
  cp /etc/gondolin/mitm/ca.crt /usr/local/share/ca-certificates/gondolin-mitm.crt
@@ -950,8 +1026,14 @@ async function resumeVm(config) {
950
1026
  const vmSshDir = `${vmAgentDir}/ssh`;
951
1027
  const hasAgentFiles = guestCredentialMode === "guest-config";
952
1028
  const providerAuthDir = config.providerAuth ? path.posix.dirname(config.providerAuth.guestPath) : null;
953
- const guestDirs = [...hasAgentFiles ? [`${vmAgentDir}/ssh`] : [], ...providerAuthDir ? [providerAuthDir] : []];
954
- 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);
955
1037
  if (creds.providerAuthJson !== null && config.providerAuth) await vm.fs.writeFile(config.providerAuth.guestPath, creds.providerAuthJson, {
956
1038
  mode: 384,
957
1039
  signal: config.signal
@@ -990,16 +1072,94 @@ async function resumeVm(config) {
990
1072
  signal: config.signal
991
1073
  });
992
1074
  }
993
- 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
+ });
994
1152
  return {
995
1153
  vm,
996
1154
  credentials: creds,
997
1155
  secretManager,
1156
+ services,
998
1157
  mountPath: config.mountPath,
999
1158
  guestWorkspace,
1000
1159
  agentDir
1001
1160
  };
1002
1161
  } catch (err) {
1162
+ servicesAbort.abort();
1003
1163
  try {
1004
1164
  await vm.close();
1005
1165
  } catch (closeErr) {
@@ -1071,4 +1231,4 @@ function rewriteMoltnetJsonPaths(moltnetJson, vmAgentDir, vmSshDir, githubAppPem
1071
1231
  return JSON.stringify(config);
1072
1232
  }
1073
1233
  //#endregion
1074
- export { AutoParentMemoryProvider, BrokeredHttpSecretBoundaryError, GONDOLIN_BASE_EXECUTABLES, GUEST_TASK_CONTEXT_MOUNT, GUEST_TASK_SKILLS_MOUNT, GuestEnvironmentBoundaryError, abortableResource, activateAgentEnv, assertGuestEnvironmentBoundary, assertHostAuthenticatedGuestEnvironment, canonicalizeBrokeredHttpSecretDescriptor, delay, ensureSnapshot, findMainWorktree, isResolvedPathInsideRoot, loadCredentials, prepareBrokeredHttpSecrets, resolveVfsShadowConfig, resolveVmAgentDir, resumeVm, 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.2.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",