@namzu/sandbox 26.0.0 → 27.0.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +20 -7
  3. package/dist/backends/firecracker/protocol.d.ts +46 -0
  4. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  5. package/dist/backends/firecracker/protocol.js +31 -0
  6. package/dist/backends/firecracker/protocol.js.map +1 -1
  7. package/dist/backends/firecracker/transport.d.ts +15 -1
  8. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  9. package/dist/backends/firecracker/transport.js +16 -0
  10. package/dist/backends/firecracker/transport.js.map +1 -1
  11. package/dist/backends/kubernetes/allocation.d.ts +115 -0
  12. package/dist/backends/kubernetes/allocation.d.ts.map +1 -0
  13. package/dist/backends/kubernetes/allocation.js +130 -0
  14. package/dist/backends/kubernetes/allocation.js.map +1 -0
  15. package/dist/backends/kubernetes/egress-policy.d.ts +111 -35
  16. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  17. package/dist/backends/kubernetes/egress-policy.js +81 -41
  18. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  19. package/dist/backends/kubernetes/identity.d.ts +129 -0
  20. package/dist/backends/kubernetes/identity.d.ts.map +1 -1
  21. package/dist/backends/kubernetes/identity.js +150 -0
  22. package/dist/backends/kubernetes/identity.js.map +1 -1
  23. package/dist/backends/kubernetes/index.d.ts +23 -1
  24. package/dist/backends/kubernetes/index.d.ts.map +1 -1
  25. package/dist/backends/kubernetes/index.js +22 -3
  26. package/dist/backends/kubernetes/index.js.map +1 -1
  27. package/dist/backends/kubernetes/objects.d.ts +76 -0
  28. package/dist/backends/kubernetes/objects.d.ts.map +1 -1
  29. package/dist/backends/kubernetes/objects.js +61 -0
  30. package/dist/backends/kubernetes/objects.js.map +1 -1
  31. package/dist/backends/kubernetes/per-sandbox-fence.d.ts +194 -0
  32. package/dist/backends/kubernetes/per-sandbox-fence.d.ts.map +1 -0
  33. package/dist/backends/kubernetes/per-sandbox-fence.js +522 -0
  34. package/dist/backends/kubernetes/per-sandbox-fence.js.map +1 -0
  35. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +45 -3
  36. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -1
  37. package/dist/backends/kubernetes/per-sandbox-policy.js +98 -8
  38. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -1
  39. package/dist/backends/kubernetes/sandbox.d.ts +4 -9
  40. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
  41. package/dist/backends/kubernetes/sandbox.js +4 -9
  42. package/dist/backends/kubernetes/sandbox.js.map +1 -1
  43. package/dist/backends/kubernetes/transport.d.ts +108 -1
  44. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  45. package/dist/backends/kubernetes/transport.js +150 -6
  46. package/dist/backends/kubernetes/transport.js.map +1 -1
  47. package/dist/backends/kubernetes/workspace-allocate.d.ts +206 -0
  48. package/dist/backends/kubernetes/workspace-allocate.d.ts.map +1 -0
  49. package/dist/backends/kubernetes/workspace-allocate.js +332 -0
  50. package/dist/backends/kubernetes/workspace-allocate.js.map +1 -0
  51. package/dist/backends/kubernetes/workspace-observe.d.ts +187 -0
  52. package/dist/backends/kubernetes/workspace-observe.d.ts.map +1 -0
  53. package/dist/backends/kubernetes/workspace-observe.js +343 -0
  54. package/dist/backends/kubernetes/workspace-observe.js.map +1 -0
  55. package/dist/backends/kubernetes/workspace-per-sandbox.d.ts +94 -0
  56. package/dist/backends/kubernetes/workspace-per-sandbox.d.ts.map +1 -0
  57. package/dist/backends/kubernetes/workspace-per-sandbox.js +119 -0
  58. package/dist/backends/kubernetes/workspace-per-sandbox.js.map +1 -0
  59. package/dist/backends/kubernetes/workspace.d.ts +139 -5
  60. package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
  61. package/dist/backends/kubernetes/workspace.js +434 -36
  62. package/dist/backends/kubernetes/workspace.js.map +1 -1
  63. package/dist/index.d.ts +83 -7
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +100 -6
  66. package/dist/index.js.map +1 -1
  67. package/package.json +3 -3
  68. package/src/backends/firecracker/protocol.ts +47 -0
  69. package/src/backends/firecracker/transport.ts +31 -0
  70. package/src/backends/kubernetes/allocation.ts +199 -0
  71. package/src/backends/kubernetes/egress-policy.ts +165 -42
  72. package/src/backends/kubernetes/identity.ts +210 -0
  73. package/src/backends/kubernetes/index.ts +61 -2
  74. package/src/backends/kubernetes/objects.ts +98 -0
  75. package/src/backends/kubernetes/per-sandbox-fence.ts +800 -0
  76. package/src/backends/kubernetes/per-sandbox-policy.ts +152 -17
  77. package/src/backends/kubernetes/sandbox.ts +4 -9
  78. package/src/backends/kubernetes/transport.ts +203 -4
  79. package/src/backends/kubernetes/workspace-allocate.ts +607 -0
  80. package/src/backends/kubernetes/workspace-observe.ts +590 -0
  81. package/src/backends/kubernetes/workspace-per-sandbox.ts +179 -0
  82. package/src/backends/kubernetes/workspace.ts +655 -34
  83. package/src/index.ts +193 -3
@@ -470,6 +470,51 @@ export interface KubernetesPerSandboxEgressConfig {
470
470
  * leave `tlsServerNames` off for a domain list.
471
471
  */
472
472
  readonly narrowing?: KubernetesCiliumEgressNarrowing
473
+ /**
474
+ * The identity the operator's fence bounds, spelled as the API server
475
+ * spells a user: `system:serviceaccount:<namespace>:<name>` for a
476
+ * ServiceAccount. When set, the fence check requires the policy's
477
+ * `matchConditions` to name exactly this identity
478
+ * (`request.userInfo.username == '<identity>'`), and refuses before any
479
+ * write when they name another one or none.
480
+ *
481
+ * Unset (the default), the identity is not checked: the shipped fence
482
+ * names `system:serviceaccount:namzu-sandboxes:namzu-sandbox-host`, and a
483
+ * host that authenticates as something else would otherwise hold the write
484
+ * verbs with a fence that never matches it — which reads exactly like an
485
+ * enforced one. Set it whenever you know who the host is.
486
+ */
487
+ readonly hostIdentity?: string
488
+ /**
489
+ * The operator-pinned set of hosts and ports a host may grant. Names the
490
+ * `ConfigMap` that holds it; see
491
+ * `k8s/manifests/validatingadmissionpolicy-cilium-permitted-hosts.yaml`
492
+ * for the companion policy that enforces it at admission and the shape of
493
+ * the ConfigMap.
494
+ *
495
+ * Set, three things change and nothing else does:
496
+ *
497
+ * - the fence check additionally proves the companion policy and its
498
+ * binding (the policy name with `-hosts` appended, and its binding),
499
+ * including that the binding's `paramRef` is THIS ConfigMap and denies
500
+ * when it is missing;
501
+ * - every `setNetworkPolicy` call with a non-empty list reads the
502
+ * ConfigMap first and refuses locally, with a named error and nothing
503
+ * written, a host or a port outside it. A narrowing that leaves a
504
+ * host's ports unrestricted is a port outside any list;
505
+ * - a missing or malformed ConfigMap refuses every such call.
506
+ *
507
+ * Unset (the default), none of that runs and behaviour is what it was.
508
+ */
509
+ readonly permittedHostsParam?: KubernetesPermittedHostsParam
510
+ }
511
+
512
+ /** Where the operator-pinned permitted hosts and ports live. */
513
+ export interface KubernetesPermittedHostsParam {
514
+ /** The `ConfigMap`'s name. */
515
+ readonly name: string
516
+ /** Its namespace. */
517
+ readonly namespace: string
473
518
  }
474
519
 
475
520
  /**
@@ -654,7 +699,14 @@ export class KubernetesPerSandboxEgressConfigError extends Error {
654
699
  override readonly name = 'KubernetesPerSandboxEgressConfigError'
655
700
 
656
701
  constructor(
657
- readonly field: 'engine' | 'admissionPolicyName' | 'admissionPolicyBindingName' | 'labelKey',
702
+ readonly field:
703
+ | 'engine'
704
+ | 'admissionPolicyName'
705
+ | 'admissionPolicyBindingName'
706
+ | 'labelKey'
707
+ | 'hostIdentity'
708
+ | 'permittedHostsParam.name'
709
+ | 'permittedHostsParam.namespace',
658
710
  readonly value: string,
659
711
  reason: string,
660
712
  ) {
@@ -755,60 +807,53 @@ export function assertPerSandboxEgressIsUsable(egress: KubernetesEgressConfig |
755
807
  if (perSandbox.narrowing !== undefined) {
756
808
  assertCiliumNarrowingIsUsable(perSandbox.narrowing, 'perSandbox.narrowing')
757
809
  }
810
+ const { hostIdentity, permittedHostsParam } = perSandbox
811
+ if (hostIdentity !== undefined && (hostIdentity === '' || hostIdentity.trim() !== hostIdentity)) {
812
+ throw new KubernetesPerSandboxEgressConfigError(
813
+ 'hostIdentity',
814
+ hostIdentity,
815
+ 'is not a user name (an empty or space-padded identity matches nothing, so the fence check would refuse every write)',
816
+ )
817
+ }
818
+ if (permittedHostsParam !== undefined) {
819
+ for (const field of ['name', 'namespace'] as const) {
820
+ const value = (permittedHostsParam as Partial<Record<'name' | 'namespace', string>>)[field]
821
+ if (typeof value !== 'string' || value === '' || value.trim() !== value) {
822
+ throw new KubernetesPerSandboxEgressConfigError(
823
+ `permittedHostsParam.${field}`,
824
+ String(value),
825
+ 'is not an object name (the parameter ConfigMap is named by both fields, and an empty or space-padded one names nothing, so every write would be refused)',
826
+ )
827
+ }
828
+ }
829
+ }
758
830
  perSandboxEgressLabelKey(egress)
759
831
  }
760
832
 
761
833
  /**
762
- * Named refusal for `config.egress.perSandbox` reaching an entry point that
763
- * can never carry the capability it configures: `createKubernetesWorkspace`.
834
+ * Formerly the refusal of `config.egress.perSandbox` on
835
+ * `createKubernetesWorkspace` and `allocateKubernetesWorkspace`, which could
836
+ * not carry the capability. They carry it now: the workspace's pod template
837
+ * gets the per-sandbox selector label, the `Sandbox` owns the policies, and
838
+ * the handle serves `setNetworkPolicy`.
764
839
  *
765
- * `perSandbox` exists to make `Sandbox.setNetworkPolicy` PRESENT on a TASK
766
- * handle, where the acquire creates the object each policy is named after and
767
- * owned by and stamps the per-sandbox pod label its selector matches, and
768
- * where the RBAC and the admission fence are the ones that bound the write.
769
- * A workspace's create path does none of that — it composes no per-sandbox
770
- * pod label and tracks no owner uid for one — so on that path the option
771
- * would be accepted and mean nothing at all: exactly the "declared and
772
- * silently unused" configuration this module refuses everywhere else. The
773
- * alternative, omitting the method and saying nothing, is how a host comes to
774
- * believe it narrowed a workspace's egress.
840
+ * Nothing throws this any more. It stays exported so an existing
841
+ * `catch (err) { if (err instanceof KubernetesWorkspacePerSandboxEgressConfigError) … }`
842
+ * keeps compiling and simply never matches; it will be removed in a later
843
+ * major.
775
844
  *
776
- * NOT a variant of {@link KubernetesPerSandboxEgressConfigError}, which is
777
- * about a `perSandbox` value this backend cannot honour ANYWHERE (an engine
778
- * with no hostname concept, an unnamed fence). This one is about a `perSandbox`
779
- * value it honours perfectly well on the other entry point, so a caller
780
- * catching the sibling for a typo'd value does not also catch a correct
781
- * configuration aimed at the wrong path.
845
+ * @deprecated No workspace entry point refuses `egress.perSandbox` any more.
782
846
  */
783
847
  export class KubernetesWorkspacePerSandboxEgressConfigError extends Error {
784
848
  override readonly name = 'KubernetesWorkspacePerSandboxEgressConfigError'
785
849
 
786
850
  constructor() {
787
851
  super(
788
- `kubernetes: config.egress.perSandbox is set, but a KubernetesWorkspace cannot carry the capability it configures: Sandbox.setNetworkPolicy is implemented on a TASK handle, whose acquire creates the object each per-sandbox policy is named after and owned by and stamps the pod label its selector matches, while a workspace's create path composes no per-sandbox pod label and tracks no owner uid for one. Accepting the option here would declare a capability this path never serves — the silent downgrade this backend refuses on every other entry point — so it is refused before anything is sent. Drop egress.perSandbox from the configuration workspaces are created from (a backend serving task sandboxes can keep it), or create a task sandbox with it, where the method is present.`,
852
+ 'kubernetes: config.egress.perSandbox is set on a workspace entry point. This error is no longer thrown: workspaces serve per-sandbox egress.',
789
853
  )
790
854
  }
791
855
  }
792
856
 
793
- /**
794
- * Refuse `config.egress.perSandbox` on the entry point that cannot carry it —
795
- * `createKubernetesWorkspace`. See
796
- * {@link KubernetesWorkspacePerSandboxEgressConfigError}.
797
- *
798
- * Deliberately not {@link assertPerSandboxEgressIsUsable}, which validates the
799
- * same config for the path that DOES honour it: a workspace has nothing to
800
- * validate the option for, whatever its `engine` or `admissionPolicyName` say,
801
- * because it serves no `setNetworkPolicy` at all. Calling that validator here
802
- * instead would be worse than saying nothing — it would report a
803
- * `perSandbox` this deployment can use as if it were in use.
804
- */
805
- export function assertWorkspaceCarriesNoPerSandboxEgress(
806
- egress: KubernetesEgressConfig | undefined,
807
- ): void {
808
- if (egress?.perSandbox === undefined) return
809
- throw new KubernetesWorkspacePerSandboxEgressConfigError()
810
- }
811
-
812
857
  /**
813
858
  * The ONE composer for a sandbox pod's `additionalPodMetadata.labels`.
814
859
  *
@@ -1686,7 +1731,7 @@ export function assertHostsFitNarrowing(
1686
1731
  * `undefined` means no port restriction: the host's `toFQDNs` rule carries
1687
1732
  * no `toPorts` at all.
1688
1733
  */
1689
- function narrowedHostPorts(
1734
+ export function narrowedHostPorts(
1690
1735
  host: string,
1691
1736
  narrowing: KubernetesCiliumEgressNarrowing,
1692
1737
  ): readonly number[] | undefined {
@@ -2482,6 +2527,18 @@ export interface EgressAllowance {
2482
2527
  readonly kind: 'NetworkPolicy' | 'CiliumNetworkPolicy'
2483
2528
  readonly name: string
2484
2529
  }
2530
+ /**
2531
+ * Further objects whose `spec` was compared to its translation by
2532
+ * {@link verifyEgressPolicyApplied}, and which the union rule therefore
2533
+ * does not judge rule by rule either. Set only by
2534
+ * {@link unionEgressAllowance}: the post-write check of a per-sandbox
2535
+ * policy is a question about TWO named objects, the operator's baseline
2536
+ * and the policy just written.
2537
+ */
2538
+ readonly alsoNamed?: readonly {
2539
+ readonly kind: 'NetworkPolicy' | 'CiliumNetworkPolicy'
2540
+ readonly name: string
2541
+ }[]
2485
2542
  }
2486
2543
 
2487
2544
  function readPortRanges(ports: unknown, defaultProtocol: string | undefined): PortSet {
@@ -2652,6 +2709,55 @@ export function egressAllowance(translated: KubernetesTranslatedEgressPolicy): E
2652
2709
  }
2653
2710
  }
2654
2711
 
2712
+ /**
2713
+ * What the pod may reach when TWO translated policies both select it: the
2714
+ * operator's baseline and one per-sandbox policy this backend wrote.
2715
+ *
2716
+ * Kubernetes unions every policy that selects a pod, so the boundary a
2717
+ * per-sandbox write is supposed to produce is exactly baseline PLUS the
2718
+ * written policy, and a third policy allowing more than that is what the
2719
+ * post-write check refuses. Each field is the union of the two sides':
2720
+ *
2721
+ * - destinations and `toFQDNs` names concatenate;
2722
+ * - `permitsEverything` is either side's, and `permitsNothing` is both
2723
+ * sides' — a `no-network` baseline permits nothing, and a baseline that
2724
+ * permits nothing plus a per-sandbox policy that permits one name permits
2725
+ * that name, not nothing;
2726
+ * - DNS lookups: a side that permits nothing contributes no names, a side
2727
+ * that does not narrow DNS contributes every name, and the union of name
2728
+ * lists is their concatenation. This is the one field where the naive
2729
+ * union is wrong: a `no-network` baseline reports `'all'` because a core
2730
+ * policy "cannot narrow DNS", and taking that at face value would let any
2731
+ * other policy open the resolver to every name beside a narrowed grant.
2732
+ *
2733
+ * Both objects are marked as named: each had its `spec` compared to its own
2734
+ * translation before this check runs, and neither's `.domain` entries (a
2735
+ * `matchName` plus a `matchPattern`) are expressible in an allowance.
2736
+ */
2737
+ export function unionEgressAllowance(
2738
+ baseline: EgressAllowance,
2739
+ added: EgressAllowance,
2740
+ ): EgressAllowance {
2741
+ const dnsOf = (side: EgressAllowance): 'all' | readonly string[] =>
2742
+ side.permitsNothing ? [] : side.dnsNarrowedTo
2743
+ const baselineDns = dnsOf(baseline)
2744
+ const addedDns = dnsOf(added)
2745
+ const dnsNarrowedTo: 'all' | readonly string[] =
2746
+ baselineDns === 'all' || addedDns === 'all'
2747
+ ? 'all'
2748
+ : [...new Set([...baselineDns, ...addedDns])]
2749
+ return {
2750
+ destinations: [...baseline.destinations, ...added.destinations],
2751
+ fqdns: [...baseline.fqdns, ...added.fqdns],
2752
+ dnsNarrowedTo,
2753
+ permitsEverything: baseline.permitsEverything || added.permitsEverything,
2754
+ permitsNothing: baseline.permitsNothing && added.permitsNothing,
2755
+ policyKind: baseline.policyKind,
2756
+ namedObject: baseline.namedObject,
2757
+ alsoNamed: [...(baseline.alsoNamed ?? []), added.namedObject, ...(added.alsoNamed ?? [])],
2758
+ }
2759
+ }
2760
+
2655
2761
  // ---------------------------------------------------------------------------
2656
2762
  // Is one policy's rule inside the translation
2657
2763
  // ---------------------------------------------------------------------------
@@ -3311,7 +3417,9 @@ function isNamedObject(
3311
3417
  kind: EgressPolicyDocument['kind'],
3312
3418
  name: string,
3313
3419
  ): boolean {
3314
- return allowance.namedObject.kind === kind && allowance.namedObject.name === name
3420
+ const matches = (entry: { readonly kind: string; readonly name: string }): boolean =>
3421
+ entry.kind === kind && entry.name === name
3422
+ return matches(allowance.namedObject) || (allowance.alsoNamed ?? []).some(matches)
3315
3423
  }
3316
3424
 
3317
3425
  /** Every core `NetworkPolicy` in the list, reduced to {@link EgressPolicyDocument}. */
@@ -3714,7 +3822,22 @@ export async function verifyEgressPolicyUnion(
3714
3822
  target: EgressVerificationTarget,
3715
3823
  signal?: AbortSignal,
3716
3824
  ): Promise<void> {
3717
- const allowance = egressAllowance(translated)
3825
+ await verifyEgressPolicyUnionAgainst(client, egressAllowance(translated), target, signal)
3826
+ }
3827
+
3828
+ /**
3829
+ * {@link verifyEgressPolicyUnion} against an allowance the caller already
3830
+ * built — the entry point for a question about more than one translation,
3831
+ * which is what the post-write check of a per-sandbox policy is: the pod's
3832
+ * boundary is the baseline's allowance plus the written policy's, see
3833
+ * {@link unionEgressAllowance}.
3834
+ */
3835
+ export async function verifyEgressPolicyUnionAgainst(
3836
+ client: KubernetesClient,
3837
+ allowance: EgressAllowance,
3838
+ target: EgressVerificationTarget,
3839
+ signal?: AbortSignal,
3840
+ ): Promise<void> {
3718
3841
  const documents: EgressPolicyDocument[] = []
3719
3842
  try {
3720
3843
  documents.push(
@@ -53,6 +53,81 @@ export interface KubernetesWorkspaceIdentity {
53
53
  readonly guestBootId: string | undefined
54
54
  }
55
55
 
56
+ /**
57
+ * The identity a host retained for a workspace, to be authenticated against
58
+ * what stands under the workspace's deterministic name before anything is
59
+ * written or reported.
60
+ *
61
+ * - `sandboxUid` — the Sandbox object. The decisive field: a delete and
62
+ * re-create under the same id changes it, and the disk with it.
63
+ * - `volumeClaimUids` — optional, keyed by `volumeClaimTemplates` entry name.
64
+ * Checked by {@link openKubernetesWorkspace} and
65
+ * {@link reconcileKubernetesWorkspace}; an entry that is absent from the
66
+ * cluster is "not there yet", not "replaced". Lifecycle writes pin the
67
+ * Sandbox uid alone, because the claims are owned by the Sandbox.
68
+ */
69
+ export interface KubernetesWorkspaceExpectedIdentity {
70
+ readonly sandboxUid: string
71
+ readonly volumeClaimUids?: Readonly<Record<string, string>>
72
+ }
73
+
74
+ /** Which part of a retained identity did not hold. */
75
+ export type KubernetesWorkspaceIdentityMismatch =
76
+ | 'sandbox-absent'
77
+ | 'sandbox-uid'
78
+ | 'volume-claim-uid'
79
+ | 'volume-claim-owner'
80
+
81
+ /**
82
+ * Thrown by {@link openKubernetesWorkspace} when what stands under the
83
+ * workspace's name is not the workspace the host retained.
84
+ *
85
+ * The refusal is about OWNERSHIP and nothing else. A `resourceVersion` that
86
+ * moved, a `Ready` condition that flipped and a condition left over from an
87
+ * earlier generation are the controller doing its work and never raise this;
88
+ * it needs a different Sandbox uid, a Sandbox that is gone, a claim with a
89
+ * different uid than the one retained, or a claim owned by another Sandbox.
90
+ * Nothing was created, woken or written to reach it, and it is terminal:
91
+ * retrying finds the same stranger.
92
+ */
93
+ export class KubernetesWorkspaceIdentityMismatchError extends Error {
94
+ override readonly name = 'KubernetesWorkspaceIdentityMismatchError'
95
+
96
+ constructor(
97
+ readonly workspaceId: string,
98
+ readonly sandboxName: string,
99
+ readonly mismatch: KubernetesWorkspaceIdentityMismatch,
100
+ /** The uid that was retained for the thing that differs. */
101
+ readonly expected: string | undefined,
102
+ /** The uid standing there now; `undefined` when nothing does. */
103
+ readonly actual: string | undefined,
104
+ /** The `volumeClaimTemplates` entry, for the two claim mismatches. */
105
+ readonly claim?: string,
106
+ ) {
107
+ super(
108
+ `kubernetes: workspace ${workspaceId} (Sandbox ${sandboxName}) is not the workspace this host retained — ${describeMismatch(mismatch, expected, actual, claim)}. Nothing was created, woken or written. The name is deterministic, so what stands under it now is not the disk the retained identity names; decide what the other one is worth before using it.`,
109
+ )
110
+ }
111
+ }
112
+
113
+ function describeMismatch(
114
+ mismatch: KubernetesWorkspaceIdentityMismatch,
115
+ expected: string | undefined,
116
+ actual: string | undefined,
117
+ claim: string | undefined,
118
+ ): string {
119
+ if (mismatch === 'sandbox-absent') {
120
+ return `no Sandbox stands there (retained uid ${String(expected)})`
121
+ }
122
+ if (mismatch === 'sandbox-uid') {
123
+ return `its Sandbox has uid ${String(actual)}, the retained one is ${String(expected)}`
124
+ }
125
+ if (mismatch === 'volume-claim-uid') {
126
+ return `its volume claim "${String(claim)}" has uid ${String(actual)}, the retained one is ${String(expected)}`
127
+ }
128
+ return `its volume claim "${String(claim)}" (uid ${String(actual)}) is owned by a different Sandbox than ${String(expected)}`
129
+ }
130
+
56
131
  /**
57
132
  * Why the guest a handle is talking to is not the guest it was talking to.
58
133
  *
@@ -259,3 +334,138 @@ function describeEvidence(evidence: KubernetesGuestEvidence): string {
259
334
  if (evidence === 'unknown') return 'the guest could not be identified at all'
260
335
  return 'the guest is unchanged'
261
336
  }
337
+
338
+ /**
339
+ * The whole guest a caller authenticated, to be handed to
340
+ * `workspace.pinGuest()`: the retained workspace identity
341
+ * ({@link KubernetesWorkspaceExpectedIdentity}, the same shape
342
+ * `openKubernetesWorkspace` takes) plus the two things only the guest side
343
+ * can say — the pod the agent runs in and the agent process itself.
344
+ *
345
+ * Every one of `sandboxUid`, `podUid` and `guestBootId` is REQUIRED, and a
346
+ * missing or empty one is refused with {@link KubernetesPinIncompleteError}
347
+ * rather than treated as "any": a pin that quietly matched everything the
348
+ * caller left blank would authenticate nothing. Read them off
349
+ * `workspace.identity` once a call has been answered, because
350
+ * `guestBootId` is `undefined` until the guest has spoken.
351
+ */
352
+ export interface KubernetesPinnedGuestIdentity extends KubernetesWorkspaceExpectedIdentity {
353
+ readonly podUid: string
354
+ readonly guestBootId: string
355
+ }
356
+
357
+ /**
358
+ * `workspace.pinGuest()` was given an identity that cannot pin anything.
359
+ * Thrown synchronously, before a view exists; nothing was sent.
360
+ */
361
+ export class KubernetesPinIncompleteError extends Error {
362
+ override readonly name = 'KubernetesPinIncompleteError'
363
+
364
+ constructor(
365
+ /** The fields that were absent, not a non-empty string, or malformed. */
366
+ readonly missing: readonly string[],
367
+ ) {
368
+ super(
369
+ `kubernetes: pinGuest() needs a complete guest identity and was given one without ${missing.join(', ')}. A pin that left a field blank would match whatever stands there, which authenticates nothing. Read sandboxUid, podUid and guestBootId from workspace.identity after a call has been answered by the guest you mean to pin.`,
370
+ )
371
+ }
372
+ }
373
+
374
+ /** Why a pinned view refused a call. */
375
+ export type KubernetesGuestPinRefusal =
376
+ /** The workspace's own identity no longer equals the pinned one. Checked before anything is dialed. */
377
+ | 'identity-changed'
378
+ /** The guest answered that it is a different agent process. It dispatched nothing. */
379
+ | 'guest-replaced'
380
+ /** The pod the view was pinned to refused its token: another pod stands there. */
381
+ | 'pod-replaced'
382
+ /** The guest agent is too old to authenticate a request, so a pin would silently not pin. Nothing was sent. */
383
+ | 'guest-unsupported'
384
+
385
+ /**
386
+ * Thrown by every call on a pinned view when the guest behind the workspace
387
+ * is not the one the view was pinned to.
388
+ *
389
+ * It means the call did NOT run: either the host refused before dialing
390
+ * (`identity-changed`, `guest-unsupported`) or the guest agent compared its
391
+ * boot id first and dispatched nothing (`guest-replaced`), or the token was
392
+ * refused unread (`pod-replaced`). The view never rebinds, so the same call
393
+ * will keep refusing; pin the new guest if that is what you want.
394
+ *
395
+ * `freshGuestBootId` is the id of the agent that refused, when it said:
396
+ * different from `expected.guestBootId` means replaced, and an agent that
397
+ * answers `KubernetesAgentRetiringError` with the SAME id is fenced, not
398
+ * replaced.
399
+ */
400
+ export class KubernetesGuestPinRefusedError extends Error {
401
+ override readonly name = 'KubernetesGuestPinRefusedError'
402
+
403
+ constructor(
404
+ readonly reason: KubernetesGuestPinRefusal,
405
+ readonly workspaceId: string,
406
+ readonly expected: KubernetesPinnedGuestIdentity,
407
+ /** The workspace's identity at the time, for `identity-changed`. */
408
+ readonly actual: KubernetesWorkspaceIdentity | undefined,
409
+ /** Which pinned field differed, for `identity-changed`. */
410
+ readonly field: 'sandboxUid' | 'volumeClaimUids' | 'podUid' | 'guestBootId' | undefined,
411
+ /** The boot id of the agent that answered, for `guest-replaced`. */
412
+ readonly freshGuestBootId: string | undefined,
413
+ options?: ErrorOptions,
414
+ ) {
415
+ super(describePinRefusal(reason, workspaceId, expected, field, freshGuestBootId), options)
416
+ }
417
+ }
418
+
419
+ function describePinRefusal(
420
+ reason: KubernetesGuestPinRefusal,
421
+ workspaceId: string,
422
+ expected: KubernetesPinnedGuestIdentity,
423
+ field: string | undefined,
424
+ fresh: string | undefined,
425
+ ): string {
426
+ const head = `kubernetes: this view of workspace ${workspaceId} is pinned to guest ${expected.guestBootId} in pod ${expected.podUid}`
427
+ if (reason === 'identity-changed') {
428
+ return `${head}, and the workspace's ${field ?? 'identity'} is no longer the pinned one. Nothing was sent. A pinned view never rebinds; pin the new guest if that is the one you mean.`
429
+ }
430
+ if (reason === 'guest-replaced') {
431
+ return `${head}, but the agent that answered is ${fresh ?? 'a different process'}. It compared its boot id before dispatching, so nothing ran. A pinned view never rebinds; pin the new guest if that is the one you mean.`
432
+ }
433
+ if (reason === 'pod-replaced') {
434
+ return `${head}, but that pod refused the pinned token, so a different pod stands there now. Nothing ran. A pinned view never rebinds; pin the new guest if that is the one you mean.`
435
+ }
436
+ return `${head}, but its guest agent does not advertise the 'pinned-guest' feature, so it would serve a request on whatever guest it is. Nothing was sent. Rebuild the workspace image from this Namzu release.`
437
+ }
438
+
439
+ /** A non-empty string, which is what every pinned identity field must be. */
440
+ function isIdentityString(value: unknown): value is string {
441
+ return typeof value === 'string' && value !== ''
442
+ }
443
+
444
+ /**
445
+ * Validate and freeze a pin request, copying it first: the caller's object is
446
+ * not read again after this returns, so mutating it cannot loosen a pin.
447
+ */
448
+ export function freezePinnedIdentity(
449
+ input: KubernetesPinnedGuestIdentity,
450
+ ): KubernetesPinnedGuestIdentity {
451
+ const source = (input ?? {}) as Partial<KubernetesPinnedGuestIdentity>
452
+ const missing: string[] = []
453
+ if (!isIdentityString(source.sandboxUid)) missing.push('sandboxUid')
454
+ if (!isIdentityString(source.podUid)) missing.push('podUid')
455
+ if (!isIdentityString(source.guestBootId)) missing.push('guestBootId')
456
+ let claims: Record<string, string> | undefined
457
+ if (source.volumeClaimUids !== undefined) {
458
+ claims = {}
459
+ for (const [name, uid] of Object.entries(source.volumeClaimUids)) {
460
+ if (!isIdentityString(uid)) missing.push(`volumeClaimUids.${name}`)
461
+ else claims[name] = uid
462
+ }
463
+ }
464
+ if (missing.length > 0) throw new KubernetesPinIncompleteError(missing)
465
+ return Object.freeze({
466
+ sandboxUid: source.sandboxUid as string,
467
+ podUid: source.podUid as string,
468
+ guestBootId: source.guestBootId as string,
469
+ ...(claims !== undefined ? { volumeClaimUids: Object.freeze(claims) } : {}),
470
+ })
471
+ }
@@ -126,12 +126,15 @@ import {
126
126
  assertPerSandboxEgressIsUsable,
127
127
  composeAdditionalPodLabels,
128
128
  defaultEgressPolicyName,
129
+ egressAllowance,
129
130
  egressProfileLabel,
130
131
  egressUnionVerificationEnabled,
131
132
  perSandboxEgressLabelKey,
132
133
  translateEgressPolicy,
134
+ unionEgressAllowance,
133
135
  verifyEgressPolicyApplied,
134
136
  verifyEgressPolicyUnion,
137
+ verifyEgressPolicyUnionAgainst,
135
138
  } from './egress-policy.js'
136
139
  import {
137
140
  type KubernetesIngressConfig,
@@ -430,6 +433,13 @@ export interface KubernetesAcquisition {
430
433
  * {@link owner}.
431
434
  */
432
435
  readonly perSandboxLabelValue?: string
436
+ /**
437
+ * The bound pod's own labels, as the API server reported them — what a
438
+ * policy's selector actually matches. Present under the same condition as
439
+ * {@link owner}; the post-write union check enumerates the policies that
440
+ * select THESE labels.
441
+ */
442
+ readonly podLabels?: Readonly<Record<string, string>>
433
443
  /** The TTL acquire stamped, which every renewal re-stamps. */
434
444
  readonly ttlSeconds: number
435
445
  /** DELETE that object. An already-gone object counts as released. */
@@ -628,7 +638,8 @@ export function buildKubernetesBackend(config: KubernetesBackendInternalConfig):
628
638
  // CONFIGURATION and never a runtime probe, so a caller's capability
629
639
  // detection cannot depend on when it asked.
630
640
  const perSandbox = config.egress?.perSandbox
631
- const fence = perSandbox === undefined ? undefined : buildAdmissionFence(client, perSandbox)
641
+ const fence =
642
+ perSandbox === undefined ? undefined : buildAdmissionFence(client, perSandbox, config.namespace)
632
643
  return {
633
644
  tier: 'microvm',
634
645
  name: 'kubernetes',
@@ -655,6 +666,17 @@ export function buildKubernetesBackend(config: KubernetesBackendInternalConfig):
655
666
  egress: { ...egress, perSandbox: egress.perSandbox },
656
667
  owner: acquisition.owner,
657
668
  selectorValue: acquisition.perSandboxLabelValue,
669
+ ...(egressBoundary !== undefined && acquisition.podLabels !== undefined
670
+ ? {
671
+ verifyUnion: (written, signal) =>
672
+ egressBoundary.verifyUnionWith(
673
+ acquisition.podLabels ?? {},
674
+ `the per-sandbox policy ${written.name} written for Sandbox ${acquisition.owner?.name} in namespace ${config.namespace}`,
675
+ written,
676
+ signal,
677
+ ),
678
+ }
679
+ : {}),
658
680
  })
659
681
  : undefined
660
682
  return await admitProbedSandbox(
@@ -690,6 +712,12 @@ export interface KubernetesEgressBoundary {
690
712
  * exact-match comparison and its once-per-boundary memo.
691
713
  */
692
714
  verifyNamedObject(signal?: AbortSignal): Promise<void>
715
+ /**
716
+ * The translated policy itself — the same one-per-boundary translation
717
+ * both checks compare against. Read-only: nothing is created or fetched
718
+ * from the cluster. `resolveKubernetesExecutionProfile` fingerprints it.
719
+ */
720
+ translatedPolicy(): Promise<KubernetesTranslatedEgressPolicy>
693
721
  /**
694
722
  * Enumerate every policy selecting THIS pod and refuse when any of them
695
723
  * allows egress the translation does not. A no-op under
@@ -700,6 +728,20 @@ export interface KubernetesEgressBoundary {
700
728
  subject: string,
701
729
  signal?: AbortSignal,
702
730
  ): Promise<void>
731
+ /**
732
+ * The same enumeration after a per-sandbox policy was WRITTEN: refuse when
733
+ * any policy selecting this pod allows more than the baseline translation
734
+ * plus `added`. Never cached — it runs once per write, against the cluster
735
+ * as it is at that moment, because a policy applied between two writes is
736
+ * exactly what it exists to notice. A no-op under
737
+ * `egress.verify: 'named-object-only'`.
738
+ */
739
+ verifyUnionWith(
740
+ podLabels: Readonly<Record<string, string>>,
741
+ subject: string,
742
+ added: KubernetesTranslatedEgressPolicy,
743
+ signal?: AbortSignal,
744
+ ): Promise<void>
703
745
  }
704
746
 
705
747
  /**
@@ -776,6 +818,7 @@ export function buildEgressBoundary(
776
818
  // resolves and a per-check throw would surface from a cache lookup.
777
819
  const perSandboxKey = perSandboxEgressLabelKey(egress)
778
820
  return {
821
+ translatedPolicy: translate,
779
822
  async verifyNamedObject(signal) {
780
823
  namedObject ??= (async () => {
781
824
  await verifyEgressPolicyApplied(client, await translate(), signal)
@@ -808,6 +851,20 @@ export function buildEgressBoundary(
808
851
  passes.set(key, { at: now(), pending })
809
852
  await pending
810
853
  },
854
+ async verifyUnionWith(podLabels, subject, added, signal) {
855
+ if (!egressUnionVerificationEnabled(egress)) return
856
+ const baseline = await translate()
857
+ await verifyEgressPolicyUnionAgainst(
858
+ client,
859
+ unionEgressAllowance(egressAllowance(baseline), egressAllowance(added)),
860
+ // 'cilium' whatever `egress.engine` says: the policy just written is
861
+ // a `CiliumNetworkPolicy`, so the Cilium collection has to be
862
+ // enumerated for a widening one to be seen at all. Under the
863
+ // default engine the create path reads core policies only.
864
+ { namespace: config.namespace, podLabels, engine: 'cilium', subject },
865
+ signal,
866
+ )
867
+ },
811
868
  }
812
869
  }
813
870
 
@@ -1667,7 +1724,9 @@ export async function acquireKubernetesSandbox(
1667
1724
  ? { refreshAgent: buildAgentAddressRefresh(client, namespace, binding, agentPort, mode) }
1668
1725
  : {}),
1669
1726
  ownedPath,
1670
- ...(owner !== undefined ? { owner, perSandboxLabelValue: objectName } : {}),
1727
+ ...(owner !== undefined
1728
+ ? { owner, perSandboxLabelValue: objectName, podLabels: pod.labels ?? podLabels }
1729
+ : {}),
1671
1730
  ttlSeconds,
1672
1731
  release,
1673
1732
  renew,