@themoltnet/sandbox-gondolin 0.4.0 → 0.5.1

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
@@ -87,6 +87,18 @@ queries. Gondolin also decodes, substitutes, and re-encodes HTTP Basic
87
87
  authorization, covering the password encoding used by HTTPS Git credential
88
88
  helpers; OAuth client secrets sent in form bodies remain host-only.
89
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
+
90
102
  Rotation and revocation do not require exposing or changing the guest
91
103
  placeholder:
92
104
 
package/dist/index.d.ts CHANGED
@@ -91,8 +91,9 @@ export declare function canonicalizeBrokeredHttpSecretDescriptor(descriptor: Bro
91
91
  * enforce the descriptor protocol and port before request or IP dispatch.
92
92
  */
93
93
  export declare function createBrokeredHttpNetworkOriginPolicy(bindings: readonly BrokeredHttpSecretDescriptor[], options?: {
94
- onDenied?: (denial: RequestOrigin & {
94
+ onDecision?: (decision: RequestOrigin & {
95
95
  phase: 'request' | 'ip';
96
+ allowed: boolean;
96
97
  }) => void;
97
98
  }): {
98
99
  isRequestAllowed: (request: Request) => boolean;
@@ -461,7 +462,7 @@ export declare interface VmCredentials {
461
462
  }
462
463
 
463
464
  export declare interface VmDiagnostic {
464
- event: 'vm.credentials.mode' | 'vm.http_secrets.bound' | 'vm.host_origins.bound' | 'vm.guest_projection.applied' | 'vm.guest_service.not_ready' | 'vm.network.origin_denied';
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';
465
466
  level: 'info' | 'warning';
466
467
  message: string;
467
468
  /** Present only for the value-free broker summary event. */
@@ -471,12 +472,18 @@ export declare interface VmDiagnostic {
471
472
  /** Present only for the guest-projection summary event. */
472
473
  projectedFileCount?: number;
473
474
  projectedServiceCount?: number;
474
- /** Present only for an exact-origin denial event. */
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. */
475
481
  origin?: {
476
482
  hostname: string;
477
483
  protocol: string;
478
484
  port: number;
479
485
  phase: 'request' | 'ip';
486
+ allowed: boolean;
480
487
  };
481
488
  }
482
489
 
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
4
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
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;
@@ -290,12 +291,24 @@ function getCacheDir() {
290
291
  const base = process.env.XDG_CACHE_HOME ?? path.join(process.env.HOME ?? "/tmp", ".cache");
291
292
  return path.join(base, "moltnet", "gondolin");
292
293
  }
294
+ /**
295
+ * The VM backend the snapshot will be built with. Gondolin checkpoints
296
+ * record `compatibleVmm` and refuse to resume under a backend the build
297
+ * did not produce assets for, so the backend MUST be part of the cache
298
+ * key: a qemu-built snapshot resumed under `GONDOLIN_VMM=krun` (the
299
+ * signed bundle's default) would otherwise fail every run until the
300
+ * cache is wiped by hand.
301
+ */
302
+ function resolveSnapshotVmm(env = process.env) {
303
+ return env.GONDOLIN_VMM?.trim().toLowerCase() === "krun" ? "krun" : "qemu";
304
+ }
293
305
  function computeConfigHash(config) {
294
306
  const h = createHash("sha256");
295
307
  h.update(JSON.stringify({
296
308
  baseAlpine: BASE_ALPINE_PACKAGES,
297
309
  ghVersion: GH_VERSION,
298
310
  cliVersion: MOLTNET_CLI_VERSION,
311
+ vmm: resolveSnapshotVmm(),
299
312
  config
300
313
  }));
301
314
  return h.digest("hex").slice(0, 12);
@@ -425,6 +438,76 @@ function pruneOldSnapshots(maxCached, currentDir) {
425
438
  });
426
439
  }
427
440
  //#endregion
441
+ //#region src/canonical-host.ts
442
+ var DECIMAL_IPV4_COMPONENTS = /^\d+(?:\.\d+){0,3}$/;
443
+ var NON_DECIMAL_IPV4 = /^(?:0x[\da-f]+|0[0-7]+)(?:\.(?:0x[\da-f]+|0[0-7]+|\d+)){0,3}$/i;
444
+ function stripTrailingDots(value) {
445
+ let end = value.length;
446
+ while (end > 0 && value.charCodeAt(end - 1) === 46) end -= 1;
447
+ return value.slice(0, end);
448
+ }
449
+ function rejectAlternateNumericAddress(hostname) {
450
+ 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");
451
+ }
452
+ /** Canonicalize one concrete DNS name or IP literal for policy comparison. */
453
+ function canonicalizeHostname(input) {
454
+ const withoutTrailingDot = stripTrailingDots(input.trim().replace(/^\[|\]$/g, ""));
455
+ if (!withoutTrailingDot) throw new Error("hostname is required");
456
+ rejectAlternateNumericAddress(withoutTrailingDot);
457
+ const ipVersion = isIP(withoutTrailingDot);
458
+ if (ipVersion === 4) return withoutTrailingDot;
459
+ if (ipVersion === 6) return new URL(`http://[${withoutTrailingDot}]/`).hostname.slice(1, -1);
460
+ if (/[\s/:@?#[\]]/.test(withoutTrailingDot)) throw new Error("invalid hostname");
461
+ const ascii = domainToASCII(withoutTrailingDot.toLowerCase());
462
+ if (!ascii || ascii.length > 253) throw new Error("invalid hostname");
463
+ return ascii;
464
+ }
465
+ /**
466
+ * Credential destinations deliberately support only exact hosts, a global
467
+ * wildcard, or a leading one-label wildcard. Arbitrary globs are too
468
+ * ambiguous for a secret-delivery boundary.
469
+ */
470
+ function canonicalizeCredentialHostPattern(input) {
471
+ const pattern = input.trim();
472
+ if (pattern === "*") return pattern;
473
+ if (pattern.startsWith("*.") && pattern.indexOf("*", 1) === -1) return `*.${canonicalizeHostname(pattern.slice(2))}`;
474
+ if (pattern.includes("*")) throw new Error("credential host patterns allow only a leading *.");
475
+ return canonicalizeHostname(pattern);
476
+ }
477
+ function credentialHostMatches(hostnameInput, patternInput) {
478
+ const hostname = canonicalizeHostname(hostnameInput);
479
+ const pattern = canonicalizeCredentialHostPattern(patternInput);
480
+ if (pattern === "*") return true;
481
+ if (!pattern.startsWith("*.")) return hostname === pattern;
482
+ const suffix = pattern.slice(2);
483
+ if (!hostname.endsWith(`.${suffix}`)) return false;
484
+ const prefix = hostname.slice(0, -(suffix.length + 1));
485
+ return prefix.length > 0 && !prefix.includes(".");
486
+ }
487
+ /** Keep Gondolin's wider network glob syntax separate from secret patterns. */
488
+ function normalizeNetworkHostPattern(input) {
489
+ const pattern = input.trim();
490
+ if (pattern === "*") return pattern;
491
+ if (!pattern.includes("*")) return canonicalizeHostname(pattern);
492
+ const normalized = stripTrailingDots(pattern.toLowerCase());
493
+ if (normalized === "" || normalized.includes("://") || normalized.includes("/") || normalized.includes(":") || /\s/.test(normalized)) throw new Error("invalid network host pattern");
494
+ return normalized;
495
+ }
496
+ function networkPatternMatchesHostname(networkPatternInput, hostnameInput) {
497
+ const networkPattern = normalizeNetworkHostPattern(networkPatternInput);
498
+ const hostname = canonicalizeHostname(hostnameInput);
499
+ const expression = networkPattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
500
+ return new RegExp(`^${expression}$`, "i").test(hostname);
501
+ }
502
+ /** Prove that a network grant covers a narrower credential destination. */
503
+ function networkPatternCoversCredentialPattern(networkPatternInput, credentialPatternInput) {
504
+ const networkPattern = normalizeNetworkHostPattern(networkPatternInput);
505
+ const credentialPattern = canonicalizeCredentialHostPattern(credentialPatternInput);
506
+ if (networkPattern === "*") return true;
507
+ if (credentialPattern.includes("*")) return networkPattern === credentialPattern;
508
+ return networkPatternMatchesHostname(networkPattern, credentialPattern);
509
+ }
510
+ //#endregion
428
511
  //#region src/host-origins.ts
429
512
  function hostOriginHostnames(origins) {
430
513
  return Object.keys(origins).map((origin) => new URL(origin).hostname).sort();
@@ -660,16 +743,6 @@ var OBJECT_META_PROPERTY_NAMES = new Set([
660
743
  "constructor",
661
744
  "prototype"
662
745
  ]);
663
- function normalizeHostPattern(pattern) {
664
- let normalized = pattern.trim().toLowerCase();
665
- if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
666
- if (normalized.endsWith(".")) normalized = normalized.slice(0, -1);
667
- return normalized;
668
- }
669
- function isValidHostPattern(pattern) {
670
- if (isIP(pattern) !== 0) return true;
671
- return pattern !== "" && !pattern.includes("://") && !pattern.includes("/") && !pattern.includes(":") && !/\s/.test(pattern);
672
- }
673
746
  /**
674
747
  * Canonicalize and validate the value-free portion of a brokered binding.
675
748
  * Runtime attestation and Gondolin enforcement both consume this function so
@@ -679,15 +752,20 @@ function canonicalizeBrokeredHttpSecretDescriptor(descriptor) {
679
752
  const issues = [];
680
753
  const id = descriptor.id.trim();
681
754
  const guestEnv = descriptor.guestEnv.trim();
682
- const hosts = [...new Set(descriptor.hosts.map(normalizeHostPattern))].sort();
755
+ const hosts = [];
683
756
  const protocolInput = descriptor.protocol ?? "https";
684
757
  const protocol = protocolInput === "http" ? "http" : "https";
685
758
  const ports = [...new Set(descriptor.ports ?? [protocol === "https" ? 443 : 80])].sort((left, right) => left - right);
686
759
  if (!BROKERED_SECRET_ID_REGEXP.test(id)) issues.push(`invalid requirement id "${id || "<empty>"}"`);
687
760
  if (!GUEST_ENV_NAME_REGEXP.test(guestEnv)) issues.push(`requirement "${id}" has invalid guest env "${guestEnv}"`);
688
761
  else if (isReservedGuestEnvironmentName(guestEnv) || OBJECT_META_PROPERTY_NAMES.has(guestEnv)) issues.push(`requirement "${id}" uses reserved guest env "${guestEnv}"`);
689
- if (hosts.length === 0) issues.push(`requirement "${id}" has no destination hosts`);
690
- for (const host of hosts) if (!isValidHostPattern(host)) issues.push(`requirement "${id}" has invalid host pattern "${host}"`);
762
+ for (const hostInput of descriptor.hosts) try {
763
+ hosts.push(canonicalizeCredentialHostPattern(hostInput));
764
+ } catch {
765
+ issues.push(`requirement "${id}" has invalid host pattern "${hostInput.trim()}"`);
766
+ }
767
+ const uniqueHosts = [...new Set(hosts)].sort();
768
+ if (descriptor.hosts.length === 0) issues.push(`requirement "${id}" has no destination hosts`);
691
769
  if (protocolInput !== "https" && protocolInput !== "http") issues.push(`requirement "${id}" has invalid protocol "${String(protocolInput)}"`);
692
770
  if (ports.length === 0) issues.push(`requirement "${id}" has no destination ports`);
693
771
  for (const port of ports) if (!Number.isInteger(port) || port < 1 || port > 65535) issues.push(`requirement "${id}" has invalid port "${port}"`);
@@ -695,16 +773,12 @@ function canonicalizeBrokeredHttpSecretDescriptor(descriptor) {
695
773
  return Object.freeze({
696
774
  id,
697
775
  guestEnv,
698
- hosts: Object.freeze(hosts),
776
+ hosts: Object.freeze(uniqueHosts),
699
777
  protocol,
700
778
  ports: Object.freeze(ports),
701
779
  required: descriptor.required !== false
702
780
  });
703
781
  }
704
- function hostMatchesPattern(hostname, pattern) {
705
- const expression = normalizeHostPattern(pattern).split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
706
- return new RegExp(`^${expression}$`, "i").test(normalizeHostPattern(hostname));
707
- }
708
782
  /**
709
783
  * Conservative subset check for Gondolin hostname globs. Exact secret hosts
710
784
  * may sit below a broader network wildcard. A wildcard secret destination must
@@ -712,9 +786,7 @@ function hostMatchesPattern(hostname, pattern) {
712
786
  * through an ambiguous glob comparison.
713
787
  */
714
788
  function networkPatternCoversSecretPattern(networkPattern, secretPattern) {
715
- if (networkPattern === "*") return true;
716
- if (secretPattern.includes("*")) return networkPattern === secretPattern;
717
- return hostMatchesPattern(secretPattern, networkPattern);
789
+ return networkPatternCoversCredentialPattern(networkPattern, secretPattern);
718
790
  }
719
791
  /**
720
792
  * Validate value-free descriptors, host-local bindings, environment
@@ -725,7 +797,7 @@ function prepareBrokeredHttpSecrets(options) {
725
797
  const ids = /* @__PURE__ */ new Set();
726
798
  const guestEnvNames = /* @__PURE__ */ new Set();
727
799
  const occupiedGuestEnvNames = new Set(options.occupiedGuestEnvNames ?? []);
728
- const allowedHosts = [...new Set(options.allowedHosts.map(normalizeHostPattern))];
800
+ const allowedHosts = [...new Set(options.allowedHosts.map(normalizeNetworkHostPattern))];
729
801
  const secrets = Object.create(null);
730
802
  for (const binding of options.bindings ?? []) {
731
803
  let descriptor;
@@ -780,7 +852,7 @@ function parseRequestOrigin(url) {
780
852
  const parsed = new URL(url);
781
853
  const protocol = parsed.protocol.slice(0, -1);
782
854
  return {
783
- hostname: normalizeHostPattern(parsed.hostname),
855
+ hostname: canonicalizeHostname(parsed.hostname),
784
856
  protocol,
785
857
  port: parsed.port ? Number(parsed.port) : protocol === "https" ? 443 : 80
786
858
  };
@@ -808,7 +880,7 @@ function createBrokeredHttpSecretOriginPolicy(bindings) {
808
880
  if (!requestOrigin) return false;
809
881
  for (const entry of entries.values()) {
810
882
  if (entry.deleted || !headersContainSecretValue(request.headers, entry.value)) continue;
811
- if (requestOrigin.protocol !== entry.protocol || !entry.ports.includes(requestOrigin.port) || !entry.hosts.some((host) => hostMatchesPattern(requestOrigin.hostname, host))) return false;
883
+ if (requestOrigin.protocol !== entry.protocol || !entry.ports.includes(requestOrigin.port) || !entry.hosts.some((host) => credentialHostMatches(requestOrigin.hostname, host))) return false;
812
884
  }
813
885
  return true;
814
886
  },
@@ -833,15 +905,21 @@ function createBrokeredHttpSecretOriginPolicy(bindings) {
833
905
  function createBrokeredHttpNetworkOriginPolicy(bindings, options = {}) {
834
906
  const origins = bindings.map(canonicalizeBrokeredHttpSecretDescriptor);
835
907
  const isAllowed = (input) => {
836
- const hostname = normalizeHostPattern(input.hostname);
837
- const matching = origins.filter((origin) => origin.hosts.some((host) => hostMatchesPattern(hostname, host)));
908
+ let hostname;
909
+ try {
910
+ hostname = canonicalizeHostname(input.hostname);
911
+ } catch {
912
+ return false;
913
+ }
914
+ const matching = origins.filter((origin) => origin.hosts.some((host) => credentialHostMatches(hostname, host)));
838
915
  if (matching.length === 0) return true;
839
916
  const allowed = matching.some((origin) => origin.protocol === input.protocol && origin.ports.includes(input.port));
840
- if (!allowed) options.onDenied?.({
917
+ options.onDecision?.({
841
918
  hostname,
842
919
  protocol: input.protocol,
843
920
  port: input.port,
844
- phase: input.phase
921
+ phase: input.phase,
922
+ allowed
845
923
  });
846
924
  return allowed;
847
925
  };
@@ -970,6 +1048,16 @@ async function resumeVm(config) {
970
1048
  const hostOriginHosts = hostOriginHostnames(hostOrigins);
971
1049
  assertInternalHostsDoNotOverlapProtectedHosts(hostOriginHosts, protectedExternalHosts);
972
1050
  const allowedInternalHosts = [...new Set([...runtimeAllowedInternalHosts, ...hostOriginHosts])];
1051
+ const requestedHostnamePolicy = Object.freeze({
1052
+ allowedHosts: Object.freeze([...allowedHosts].sort()),
1053
+ allowedInternalHosts: Object.freeze([...allowedInternalHosts].sort())
1054
+ });
1055
+ config.onDiagnostic?.({
1056
+ event: "vm.network.policy_bound",
1057
+ level: "info",
1058
+ message: "Passed the complete requested hostname policy to Gondolin",
1059
+ hostnamePolicy: requestedHostnamePolicy
1060
+ });
973
1061
  const projectedEnv = config.guestProjection?.env ?? {};
974
1062
  const brokeredSecrets = prepareBrokeredHttpSecrets({
975
1063
  bindings: config.brokeredSecrets,
@@ -987,12 +1075,18 @@ async function resumeVm(config) {
987
1075
  ]
988
1076
  });
989
1077
  const brokeredSecretOriginPolicy = createBrokeredHttpSecretOriginPolicy(config.brokeredSecrets ?? []);
990
- const brokeredNetworkOriginPolicy = createBrokeredHttpNetworkOriginPolicy(config.brokeredSecrets ?? [], { onDenied(origin) {
1078
+ const brokeredNetworkOriginPolicy = createBrokeredHttpNetworkOriginPolicy(config.brokeredSecrets ?? [], { onDecision(decision) {
991
1079
  config.onDiagnostic?.({
1080
+ event: "vm.network.origin_checked",
1081
+ level: "info",
1082
+ message: "Checked a canonical brokered credential origin",
1083
+ origin: decision
1084
+ });
1085
+ if (!decision.allowed) config.onDiagnostic?.({
992
1086
  event: "vm.network.origin_denied",
993
1087
  level: "warning",
994
- message: "denied request outside a brokered credential origin",
995
- origin
1088
+ message: "Denied a request outside a brokered credential origin",
1089
+ origin: decision
996
1090
  });
997
1091
  } });
998
1092
  const { httpHooks, env: secretEnv, secretManager: gondolinSecretManager } = createHttpHooks({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/sandbox-gondolin",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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",
@@ -33,9 +33,9 @@
33
33
  },
34
34
  "nx": {
35
35
  "tags": [
36
- "type:runtime",
36
+ "type:sandbox",
37
37
  "scope:agent",
38
- "platform:extension"
38
+ "platform:server"
39
39
  ],
40
40
  "targets": {
41
41
  "test-ci": {