@namzu/sandbox 15.0.0 → 16.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.
@@ -170,12 +170,16 @@ export type KubernetesEgressPolicy = EgressPolicy | KubernetesOnlyEgressPolicy
170
170
  *
171
171
  * - `'union'` (the default) reads the NAMED object exactly as before AND
172
172
  * enumerates every `NetworkPolicy` — and, under `engine: 'cilium'`, every
173
- * `CiliumNetworkPolicy` — in the namespace, refusing when any policy that
174
- * selects the pod allows egress the configured translation does not. That
175
- * is not belt-and-braces: the API server UNIONS every policy selecting a
176
- * pod, so a second policy widens egress however exactly the named one
177
- * matches, and a `SandboxTemplate`'s own `networkPolicy` block becomes
178
- * exactly such a policy.
173
+ * `CiliumNetworkPolicy` — in the namespace, refusing when any OTHER policy
174
+ * that selects the pod allows egress the configured translation does not.
175
+ * The named object is left to the exact comparison, which is stronger than
176
+ * anything this rule could conclude about it — see
177
+ * {@link EgressPolicyDocument.namedObject} — and still counts as the policy
178
+ * that default-denies, so it is the enumeration's subject rather than an
179
+ * exception to it. That is not belt-and-braces: the API server UNIONS every
180
+ * policy selecting a pod, so a second policy widens egress however exactly
181
+ * the named one matches, and a `SandboxTemplate`'s own `networkPolicy`
182
+ * block becomes exactly such a policy.
179
183
  * - `'named-object-only'` is the documented opt-out, and restores the
180
184
  * previous behaviour exactly: one GET of the named object, memoized for
181
185
  * the backend's lifetime, and no enumeration. For a deployment whose
@@ -1111,9 +1115,9 @@ export class KubernetesEgressNarrowingUnsupportedError extends Error {
1111
1115
  }
1112
1116
 
1113
1117
  /**
1114
- * The grammar a host refusal normally closes with: what an `allowedHosts`
1115
- * entry is. Stated only where the translation actually implements it — see
1116
- * `stateHostnameGrammar` on {@link KubernetesNetworkPolicyHostError}.
1118
+ * The grammar a host refusal closes with: what an `allowedHosts` entry is.
1119
+ * Every translation in this module implements it, which is why nothing turns
1120
+ * it off — see {@link KubernetesNetworkPolicyHostError}.
1117
1121
  */
1118
1122
  const HOSTNAME_GRAMMAR_SENTENCE =
1119
1123
  "Entries are hostnames: 'api.example.com' for one host, '.example.com' for that domain and its subdomains."
@@ -1130,16 +1134,6 @@ const HOSTNAME_GRAMMAR_SENTENCE =
1130
1134
  * `CiliumNetworkPolicy` carrying one is an object the API server rejects on
1131
1135
  * apply — or, worse, accepts as a name that resolves to nothing, which reads
1132
1136
  * from the outside exactly like a policy that is working.
1133
- *
1134
- * `stateHostnameGrammar` is the one part of the message a caller can turn
1135
- * off, because NOT every refusal can make that claim. It is false for the
1136
- * config-level refusal of a leading-dot entry under `tlsServerNames`, whose
1137
- * body has just said that the entry reaches the object as a literal
1138
- * `matchName` admitting nothing with the option on or off: closing that
1139
- * message with "'.example.com' for that domain and its subdomains" would
1140
- * re-assert, as implemented, the very grammar whose absence is why the entry
1141
- * is refused. Every other refusal keeps the sentence, the expanding branch of
1142
- * the same one included.
1143
1137
  */
1144
1138
  export class KubernetesNetworkPolicyHostError extends Error {
1145
1139
  override readonly name = 'KubernetesNetworkPolicyHostError'
@@ -1147,15 +1141,114 @@ export class KubernetesNetworkPolicyHostError extends Error {
1147
1141
  constructor(
1148
1142
  readonly host: string,
1149
1143
  reason: string,
1150
- options: { readonly stateHostnameGrammar?: boolean } = {},
1151
1144
  ) {
1152
- const grammar = options.stateHostnameGrammar === false ? '' : ` ${HOSTNAME_GRAMMAR_SENTENCE}`
1153
1145
  super(
1154
- `kubernetes: the allowedHosts entry ${JSON.stringify(host)} is refused: it ${reason}.${grammar} Nothing was written.`,
1146
+ `kubernetes: the allowedHosts entry ${JSON.stringify(host)} is refused: it ${reason}. ${HOSTNAME_GRAMMAR_SENTENCE} Nothing was written.`,
1155
1147
  )
1156
1148
  }
1157
1149
  }
1158
1150
 
1151
+ /** A DNS name, lowercase, no scheme, no port, no wildcard. */
1152
+ const DNS_NAME = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/
1153
+
1154
+ /**
1155
+ * The one grammar an `allowedHosts` entry has to satisfy, on EVERY path that
1156
+ * emits one.
1157
+ *
1158
+ * It lives here, beside the class it throws, rather than in the per-sandbox
1159
+ * module that first needed it: it is called from {@link
1160
+ * buildCiliumEgressManifest} — the single builder both translations go
1161
+ * through — so the config-level allowlist and a `setNetworkPolicy` list are
1162
+ * refused the SAME entries, by name, with nothing emitted. That is the
1163
+ * contract {@link KubernetesNetworkPolicyHostError} already states — it
1164
+ * names entries rather than a config field, for exactly this reason — and a
1165
+ * grammar only ONE of the two translations enforces is not one the other has:
1166
+ * that is how the config-level translation came to pass `'.com'`, `'.'`,
1167
+ * `'..example.com'`, `'*'` and an IP literal straight into the emitted
1168
+ * `toFQDNs`, where the per-sandbox path refused every one of them.
1169
+ *
1170
+ * The per-sandbox writer still calls it too — {@link normalizeHost}, which
1171
+ * lowercases and then calls this — because there it also CANONICALISES the
1172
+ * bytes, before the fence is read and before anything is queued.
1173
+ */
1174
+ export function assertUsableHost(entry: string): void {
1175
+ // The two reasons the per-sandbox writer's own type check used to give
1176
+ // first, kept apart here so an untyped caller meets the same sentence
1177
+ // whichever path reached this — see the note on lowercasing below.
1178
+ if (typeof entry !== 'string') {
1179
+ throw new KubernetesNetworkPolicyHostError(String(entry), 'is not a string')
1180
+ }
1181
+ if (entry === '') {
1182
+ throw new KubernetesNetworkPolicyHostError(entry, 'is empty')
1183
+ }
1184
+ const bare = entry.startsWith('.') ? entry.slice(1) : entry
1185
+ if (bare === '') {
1186
+ throw new KubernetesNetworkPolicyHostError(entry, 'names no domain after its leading dot')
1187
+ }
1188
+ if (entry.includes('*')) {
1189
+ throw new KubernetesNetworkPolicyHostError(
1190
+ entry,
1191
+ "contains a glob; a domain and its subdomains are written with a leading dot ('.example.com'), which becomes matchName plus matchPattern",
1192
+ )
1193
+ }
1194
+ if (!DNS_NAME.test(bare)) {
1195
+ throw new KubernetesNetworkPolicyHostError(
1196
+ entry,
1197
+ 'is not a DNS name (a scheme, a path, a port suffix and an IP address all land here; letter case is canonicalised before this check, so it is never the cause)',
1198
+ )
1199
+ }
1200
+ if (bare.length > 253) {
1201
+ throw new KubernetesNetworkPolicyHostError(entry, 'is longer than a DNS name may be')
1202
+ }
1203
+ // A leading-dot entry becomes `matchPattern: '*.<domain>'`, and a
1204
+ // single-label domain there is a whole public suffix — `.com`, `.org`.
1205
+ // That is not an allowlist entry: it admits every name under a registry
1206
+ // the caller does not control. Cilium would match it if it were applied,
1207
+ // which is why this is a refusal rather than a lenient pass, and the
1208
+ // shipped admission fence refuses the same pattern for the per-sandbox
1209
+ // writes it binds (its `matchConditions` scope it to the sandbox host's
1210
+ // ServiceAccount, so an operator applying the config-level object is not
1211
+ // covered by it — one more reason the refusal has to come from here).
1212
+ if (entry.startsWith('.') && !bare.includes('.')) {
1213
+ throw new KubernetesNetworkPolicyHostError(
1214
+ entry,
1215
+ "names a whole top-level domain ('.com' means every name under it); a domain entry needs at least two labels, as in '.example.com', and the '*.com' pattern this would emit is one the shipped admission policy refuses for the per-sandbox writes it binds",
1216
+ )
1217
+ }
1218
+ // An IPv4 literal passes the grammar above — every label is digits, and
1219
+ // digits are legal in a DNS label. It is still not a hostname: a DNS
1220
+ // top-level label is never all-numeric, and Cilium's `matchName` is
1221
+ // compared against names the DNS proxy SAW, which an address never is. A
1222
+ // policy carrying one is admitted and matches nothing, which reads from
1223
+ // outside exactly like a policy that is working.
1224
+ const lastLabel = bare.slice(bare.lastIndexOf('.') + 1)
1225
+ if (/^[0-9]+$/.test(lastLabel)) {
1226
+ throw new KubernetesNetworkPolicyHostError(
1227
+ entry,
1228
+ 'ends in an all-numeric label, so it is an address rather than a hostname; toFQDNs matches names a DNS lookup returned, and an address is never one of them (use config.egress.policy for address-based egress)',
1229
+ )
1230
+ }
1231
+ }
1232
+
1233
+ /**
1234
+ * Every entry of one allowlist, judged by {@link assertUsableHost} — called
1235
+ * from {@link buildCiliumEgressManifest} before anything is emitted, so BOTH
1236
+ * translations refuse the same entries.
1237
+ *
1238
+ * The entry is lowercased for the DECISION and not for the bytes. The
1239
+ * per-sandbox writer canonicalises before it validates (`normalizeHost`), so
1240
+ * judging the lowercased form is what makes the two paths agree on WHICH
1241
+ * entries are refused — while the emitted `matchName` stays the string the
1242
+ * caller wrote, exactly as every release before this one emitted it. This
1243
+ * call refuses what no translation can express, not what one of them would
1244
+ * spell differently.
1245
+ */
1246
+ function assertHostsAreUsable(allowedHosts: readonly string[]): void {
1247
+ for (const host of allowedHosts) {
1248
+ assertUsableHost(typeof host === 'string' ? host.toLowerCase() : host)
1249
+ }
1250
+ }
1251
+
1159
1252
  /**
1160
1253
  * Every entry in `narrowing.ports`, `narrowing.hostPorts` and
1161
1254
  * `narrowing.tlsPorts` is a port the API server will actually accept, none of
@@ -1491,17 +1584,23 @@ function ciliumPortEntry(port: number): Readonly<Record<string, unknown>> {
1491
1584
  }
1492
1585
 
1493
1586
  /**
1494
- * Which translation a host refusal is being spelled for, as ONE value.
1587
+ * Which field a host refusal sends the reader to — the ONE thing about a
1588
+ * refusal that is still a fact about the caller rather than about the entry.
1589
+ *
1590
+ * It was two facts while the two translations differed in what they emitted:
1591
+ * the sentence, which followed from whether the translation expanded a
1592
+ * leading-dot entry, and the field path, which followed from which field the
1593
+ * caller set. The config-level translation used to expand nothing, so it was
1594
+ * both a different sentence and a different field; it now expands exactly as
1595
+ * the per-sandbox one does, one entry no longer means two things, and the
1596
+ * sentence is the same one on every path — so the field path is all that is
1597
+ * left to get right, which is why it is the only field here.
1495
1598
  *
1496
- * The refusal has two halves that have to agree — the sentence, which follows
1497
- * from whether the translation expands a leading-dot entry, and the field
1498
- * path, which follows from which field the caller set — and both are facts
1499
- * about the SAME thing: the translation the caller is on. Passed as two
1500
- * arguments they are decided from two inputs, and an expanding caller was
1501
- * told to repair `config.egress.ciliumNarrowing` with the remedy that belongs
1502
- * to `config.egress.perSandbox.narrowing` — a message sending a reader to a
1503
- * field the entry never came from. Neither half means anything without the
1504
- * other, so they travel together.
1599
+ * It still travels as one value rather than as a loose string argument,
1600
+ * because there are exactly two legal ones and both are named below: a refusal
1601
+ * is raised from the translation AND, earlier still, from the per-sandbox
1602
+ * writer before it reads the fence, and the two have to name the same field
1603
+ * for one entry however the earlier check is reached or skipped.
1505
1604
  */
1506
1605
  export interface HostsFitNarrowingContext {
1507
1606
  /**
@@ -1509,19 +1608,11 @@ export interface HostsFitNarrowingContext {
1509
1608
  * — the full path, as every other refusal in this module spells one.
1510
1609
  */
1511
1610
  readonly fieldPath: string
1512
- /**
1513
- * Whether this translation turns a leading-dot entry into a `matchName`
1514
- * plus a `matchPattern` — {@link ciliumFqdnEntries}. `true` for the
1515
- * per-sandbox translation, `false` for the config-level one, whose emitted
1516
- * bytes are pinned. Decides the refusal's sentence and its closing grammar
1517
- * as well as the bytes, and all three are the same fact about one entry.
1518
- */
1519
- readonly expandsDottedEntries: boolean
1520
1611
  }
1521
1612
 
1522
1613
  /**
1523
- * The refusal context of the PER-SANDBOX translation — the one that expands,
1524
- * and so the one `expandDomains: true` builds.
1614
+ * The refusal context of the PER-SANDBOX translation — `egress.perSandbox`,
1615
+ * whose allowlist comes from a host's own `setNetworkPolicy` call.
1525
1616
  *
1526
1617
  * Shared deliberately: the per-sandbox writer refuses a host by this context
1527
1618
  * before it reads the fence, and {@link buildCiliumEgressManifest} refuses the
@@ -1531,16 +1622,14 @@ export interface HostsFitNarrowingContext {
1531
1622
  */
1532
1623
  export const PER_SANDBOX_NARROWING_REFUSAL: HostsFitNarrowingContext = {
1533
1624
  fieldPath: 'config.egress.perSandbox.narrowing',
1534
- expandsDottedEntries: true,
1535
1625
  }
1536
1626
 
1537
1627
  /**
1538
1628
  * The refusal context of the config-level translation — `config.egress.policy`
1539
- * and its `config.egress.ciliumNarrowing`, which expands nothing.
1629
+ * and its `config.egress.ciliumNarrowing`.
1540
1630
  */
1541
1631
  export const CONFIG_LEVEL_NARROWING_REFUSAL: HostsFitNarrowingContext = {
1542
1632
  fieldPath: 'config.egress.ciliumNarrowing',
1543
- expandsDottedEntries: false,
1544
1633
  }
1545
1634
 
1546
1635
  /**
@@ -1549,42 +1638,32 @@ export const CONFIG_LEVEL_NARROWING_REFUSAL: HostsFitNarrowingContext = {
1549
1638
  * `tlsServerNames` puts the entry on the rule as a TLS server name — one
1550
1639
  * exact SNI value a handshake presents — and a `.domain` entry is a set of
1551
1640
  * names no single SNI value means. Either way the emitted object would be
1552
- * admitted by the shipped fence, read back deep-equal to what was sent, and
1553
- * deny what the caller asked to allow — the failure every other refusal in
1554
- * this module exists to prevent. Refused rather than translated another way,
1641
+ * applied without complaint — nothing refuses an operator's apply: the
1642
+ * shipped admission fence's `matchConditions` scope it to the sandbox host's
1643
+ * ServiceAccount, so the config-level object is not matched by it — read back
1644
+ * deep-equal to what was sent, and deny what the caller asked to allow — the
1645
+ * failure every other refusal in this module exists to prevent. Refused
1646
+ * rather than translated another way,
1555
1647
  * because both other ways are guesses: dropping the subdomains silently
1556
1648
  * narrows what the caller asked for, and a wildcard SNI value is not
1557
1649
  * something the Cilium versions these manifests are written against are
1558
1650
  * known here to match — an SNI that matches nothing denies just as
1559
1651
  * completely, and more quietly.
1560
1652
  *
1561
- * The entry is refused for a DIFFERENT reason on each of the two paths, and
1562
- * the message says which one the caller is on, because the same sentence
1563
- * cannot be true of both:
1564
- *
1565
- * - `expandDomains` — the per-sandbox writer's translation, which turns
1566
- * `.domain` into `matchName: domain` PLUS `matchPattern: '*.domain'`. No
1567
- * single SNI value means that pair: `domain` alone denies every subdomain
1568
- * the pattern admits, and the entry as written is not a name any handshake
1569
- * presents at all.
1570
- * - unexpanded — the config-level translation, which expands nothing. There
1571
- * the entry reaches the object as the literal `matchName: '.domain'`,
1572
- * which no DNS answer carries, so it already admits nothing; `serverNames`
1573
- * on top of it is a second, independent denial. Telling this caller to
1574
- * "leave tlsServerNames off for a domain list" would name a repair that is
1575
- * not one — the config-level `.domain` entry denies the domain and every
1576
- * subdomain with or without the option (a pre-existing defect of this
1577
- * translation, deferred to its own change) — so it is not offered here.
1578
- *
1579
- * `context` is WHICH translation the refusal is being spelled for, as one
1580
- * value — see {@link HostsFitNarrowingContext}. A reader has to be sent to
1581
- * the field they actually set, and the sentence they are sent by has to be
1582
- * true of the translation they are on, and both follow from that one fact:
1583
- * passed separately, an expanding caller gets the per-sandbox remedy attached
1584
- * to the config-level field name, a message whose only repair is a field the
1585
- * entry never came from. Called from BOTH — the translation itself, so every
1586
- * caller is covered, and the per-sandbox writer, which refuses earlier still,
1587
- * before it reads the fence.
1653
+ * One sentence, because one entry now means one thing: both translations turn
1654
+ * `.domain` into `matchName: domain` PLUS `matchPattern: '*.domain'` — see
1655
+ * {@link ciliumFqdnEntries} — and no single SNI value means that pair.
1656
+ * `domain` alone denies every subdomain the pattern admits, and the entry as
1657
+ * written is not a name any handshake presents at all.
1658
+ *
1659
+ * `context` is the field the refusal sends the reader to — see
1660
+ * {@link HostsFitNarrowingContext}. A reader has to be sent to the field they
1661
+ * actually set: passed as a loose string, an expanding caller was told to
1662
+ * repair `config.egress.ciliumNarrowing` with the remedy that belongs to
1663
+ * `config.egress.perSandbox.narrowing`, a message whose only repair is a field
1664
+ * the entry never came from. Called from BOTH — the translation itself, so
1665
+ * every caller is covered, and the per-sandbox writer, which refuses earlier
1666
+ * still, before it reads the fence.
1588
1667
  */
1589
1668
  export function assertHostsFitNarrowing(
1590
1669
  allowedHosts: readonly string[],
@@ -1596,16 +1675,7 @@ export function assertHostsFitNarrowing(
1596
1675
  if (!host.startsWith('.')) continue
1597
1676
  throw new KubernetesNetworkPolicyHostError(
1598
1677
  host,
1599
- context.expandsDottedEntries
1600
- ? `names a domain and its subdomains while ${context.fieldPath}.tlsServerNames is on; a TLS server name is one exact SNI value a handshake presents, and this entry becomes a name plus a '*.domain' pattern, which no single value means — list the exact hosts, or leave tlsServerNames off for a domain list`
1601
- : `names a domain and its subdomains while ${context.fieldPath}.tlsServerNames is on; a TLS server name is one exact SNI value a handshake presents, and this translation does not expand a leading-dot entry — it reaches the object as the literal matchName ${JSON.stringify(host)}, which no DNS answer carries — so the entry admits nothing as written, and serverNames on top of it is a second, independent denial. List the exact hosts this policy should allow; leaving tlsServerNames off does not repair the entry here`,
1602
- // The closing grammar rides the same input as the sentence, and for
1603
- // the same reason: the unexpanded body has just said this entry
1604
- // admits nothing as written, so a tail asserting that
1605
- // `.example.com` means the domain and its subdomains would state,
1606
- // as implemented, the grammar the refusal exists because this
1607
- // translation does not apply to it.
1608
- { stateHostnameGrammar: context.expandsDottedEntries },
1678
+ `names a domain and its subdomains while ${context.fieldPath}.tlsServerNames is on; a TLS server name is one exact SNI value a handshake presents, and this entry becomes a name plus a '*.domain' pattern, which no single value means — list the exact hosts, or leave tlsServerNames off for a domain list`,
1609
1679
  )
1610
1680
  }
1611
1681
  }
@@ -1635,21 +1705,28 @@ function narrowedHostPorts(
1635
1705
  * host's TLS ports and a plain entry for whatever is left, when
1636
1706
  * `tlsServerNames` is on.
1637
1707
  *
1638
- * `serverNames` carries the allowlist ENTRY as written, which is the same
1639
- * string as the `toFQDNs` entry only while nothing expanded it. A TLS server
1640
- * name is one exact SNI value a handshake presents, and a `.example.com`
1641
- * entry — whose `toFQDNs` half is `example.com` PLUS `*.example.com` — has no
1642
- * single SNI value meaning that set: `example.com` would deny every subdomain
1643
- * the pattern admits, and `.example.com` is not a name any handshake ever
1644
- * presents. So {@link assertHostsFitNarrowing} refuses that one combination
1645
- * before this rule is built, from `buildCiliumEgressManifest` for every
1646
- * caller and from the per-sandbox writer before it reads the fence — which is
1647
- * why this function can take `host` and the entry as one string.
1708
+ * `fqdns` is {@link ciliumFqdnEntries} of the host's allowlist entry, passed
1709
+ * in rather than derived here because the entry and the host are not always
1710
+ * the same string: `.example.com` becomes `example.com` plus
1711
+ * `*.example.com`. It is REQUIRED, with no `[{ matchName: host }]` default —
1712
+ * a caller that omitted it would emit the entry unexpanded, which is the
1713
+ * exact object this translation stopped producing.
1714
+ *
1715
+ * `serverNames` carries the allowlist ENTRY as written while the `toFQDNs`
1716
+ * half carries the expansion of it. A TLS server name is one exact SNI value
1717
+ * a handshake presents, and a `.example.com` entry — whose `toFQDNs` half is
1718
+ * `example.com` PLUS `*.example.com` — has no single SNI value meaning that
1719
+ * set: `example.com` would deny every subdomain the pattern admits, and
1720
+ * `.example.com` is not a name any handshake ever presents. So
1721
+ * {@link assertHostsFitNarrowing} refuses that one combination before this
1722
+ * rule is built, from `buildCiliumEgressManifest` for every caller and from
1723
+ * the per-sandbox writer before it reads the fence — which is why this
1724
+ * function can still take `host` and the entry as one string.
1648
1725
  */
1649
1726
  function narrowedHostFqdnRule(
1650
1727
  host: string,
1651
1728
  narrowing: KubernetesCiliumEgressNarrowing,
1652
- fqdns: readonly Readonly<Record<string, string>>[] = [{ matchName: host }],
1729
+ fqdns: readonly Readonly<Record<string, string>>[],
1653
1730
  ): Readonly<Record<string, unknown>> {
1654
1731
  const ports = narrowedHostPorts(host, narrowing)
1655
1732
  if (ports === undefined) return { toFQDNs: fqdns }
@@ -1673,18 +1750,20 @@ function narrowedHostFqdnRule(
1673
1750
  /**
1674
1751
  * One allowlist entry, expanded to the `toFQDNs` entries it means.
1675
1752
  *
1676
- * `SandboxNetworkPolicy.allowedHosts`'s own grammar, which the SDK states and
1677
- * the docker backend implements: `api.example.com` is that host, and
1678
- * `.example.com` is the domain AND its subdomains. Cilium's `matchName` is an
1679
- * exact name and does not match across a `.`, so the domain form needs the
1680
- * name plus a `matchPattern` — `*.example.com` alone would admit
1681
- * `a.example.com` and not `example.com` itself.
1682
- *
1683
- * Used by the PER-SANDBOX policy only. The config-level translation
1684
- * (`config.egress.policy`) deliberately does not expand anything: what it
1685
- * emits for a given config is pinned byte-for-byte, because verification of
1686
- * the named object is an exact match and a changed translation fails every
1687
- * `create()` on every deployment that already applied a policy.
1753
+ * `SandboxNetworkPolicy.allowedHosts`'s own grammar, which the SDK states —
1754
+ * `packages/sdk/src/types/sandbox/index.ts`'s `allowedHosts` — and which
1755
+ * `src/egress/allowlist.ts` implements for the docker backend: `api.example.com`
1756
+ * is that host, and `.example.com` is the domain AND its subdomains. Cilium's
1757
+ * `matchName` is an exact name and does not match across a `.`, so the domain
1758
+ * form needs the name plus a `matchPattern` — `*.example.com` alone would
1759
+ * admit `a.example.com` and not `example.com` itself.
1760
+ *
1761
+ * Used by BOTH translations. The config-level one used to emit a leading-dot
1762
+ * entry verbatim instead, which no DNS answer carries and so denied the
1763
+ * domain and every subdomain it was asked to allow, after a create that
1764
+ * reported success — but "the config-level bytes are pinned" was never a
1765
+ * contract the pinned bytes honoured, and one entry has to mean one thing on
1766
+ * every backend.
1688
1767
  */
1689
1768
  export function ciliumFqdnEntries(entry: string): readonly Readonly<Record<string, string>>[] {
1690
1769
  if (!entry.startsWith('.')) return [{ matchName: entry }]
@@ -1693,16 +1772,21 @@ export function ciliumFqdnEntries(entry: string): readonly Readonly<Record<strin
1693
1772
  }
1694
1773
 
1695
1774
  /**
1696
- * The DNS-visibility rule narrowed to an exact `matchName` per allowed host
1697
- * plus the host under every search suffix, replacing
1775
+ * The DNS-visibility rule narrowed to the names an allowlist entry admits:
1776
+ * an exact `matchName` per allowed host plus the host under every search
1777
+ * suffix, and — for an entry that admits subdomains — the `matchPattern` that
1778
+ * admits them, under the bare name and under every suffix as well. Replaces
1698
1779
  * {@link CILIUM_DNS_VISIBILITY_RULE}'s `matchPattern: '*'`. See
1699
1780
  * {@link KubernetesCiliumDnsNarrowing}.
1781
+ *
1782
+ * Both halves follow from {@link ciliumFqdnEntries}: the DNS proxy has to see
1783
+ * exactly what the `toFQDNs` rule admits, or the half that learns addresses
1784
+ * from a lookup never sees one the other half allows.
1700
1785
  */
1701
1786
  function narrowedDnsVisibilityRule(
1702
1787
  allowedHosts: readonly string[],
1703
1788
  fallbackNamespace: string,
1704
1789
  dnsNames: KubernetesCiliumDnsNarrowing,
1705
- expandDomains = false,
1706
1790
  ): Readonly<Record<string, unknown>> {
1707
1791
  const namespace = dnsNames.namespace ?? fallbackNamespace
1708
1792
  const clusterDomain = dnsNames.clusterDomain ?? 'cluster.local'
@@ -1716,11 +1800,8 @@ function narrowedDnsVisibilityRule(
1716
1800
  for (const entry of allowedHosts) {
1717
1801
  // A `.domain` entry resolves through its subdomains as well, so the
1718
1802
  // DNS proxy has to be allowed to SEE those lookups or `toFQDNs` never
1719
- // learns the addresses they resolve to. Off for the config-level
1720
- // translation, whose emitted bytes are pinned: `expandDomains` is
1721
- // false there and `host === entry`, so this loop is what it always
1722
- // was.
1723
- const wildcard = expandDomains && entry.startsWith('.')
1803
+ // learns the addresses they resolve to.
1804
+ const wildcard = entry.startsWith('.')
1724
1805
  const host = wildcard ? entry.slice(1) : entry
1725
1806
  matchNames.push({ matchName: host })
1726
1807
  if (wildcard) matchNames.push({ matchPattern: `*.${host}` })
@@ -1730,9 +1811,9 @@ function narrowedDnsVisibilityRule(
1730
1811
  // with fewer dots than the cluster's `ndots` tries the search
1731
1812
  // suffixes FIRST, and a lookup the DNS proxy refuses is not an
1732
1813
  // NXDOMAIN the resolver walks past — it can fail the whole
1733
- // resolution. An EXPANDED entry admits subdomains, so its
1734
- // subdomains need the same treatment, or `a.example.com` fails on
1735
- // its first search-suffix attempt under a rule that allows it.
1814
+ // resolution. An entry that admits subdomains needs the pattern
1815
+ // under each suffix too, or `a.example.com` fails on its first
1816
+ // search-suffix attempt under a rule that allows it.
1736
1817
  if (wildcard) matchNames.push({ matchPattern: `*.${host}.${suffix}` })
1737
1818
  }
1738
1819
  }
@@ -1751,13 +1832,18 @@ function narrowedDnsVisibilityRule(
1751
1832
  * Everything a `CiliumNetworkPolicy` for a hostname allowlist is built from.
1752
1833
  *
1753
1834
  * ONE builder, two callers: the config-level translation below, whose
1754
- * selector is the template (and profile) label and whose emitted bytes are
1755
- * pinned, and the per-sandbox policy in `per-sandbox-policy.ts`, whose
1756
- * selector is one per-sandbox label and which additionally carries an
1757
- * `ownerReferences` entry so the cluster garbage-collects it. A second
1758
- * builder would be a second answer to what a namzu egress policy looks like,
1759
- * and the read-back comparator would then be verifying one of them against
1760
- * the other's shape.
1835
+ * selector is the template (and profile) label, and the per-sandbox policy in
1836
+ * `per-sandbox-policy.ts`, whose selector is one per-sandbox label and which
1837
+ * additionally carries an `ownerReferences` entry so the cluster
1838
+ * garbage-collects it. A second builder would be a second answer to what a
1839
+ * namzu egress policy looks like, and the read-back comparator would then be
1840
+ * verifying one of them against the other's shape.
1841
+ *
1842
+ * The two callers now differ in nothing this interface cannot spell out: the
1843
+ * same entries become the same bytes and a refusal uses the same sentence on
1844
+ * both, so what is left of "which caller this is" is
1845
+ * {@link CiliumEgressManifestOptions.refusalContext} — the field a refusal
1846
+ * names.
1761
1847
  */
1762
1848
  export interface CiliumEgressManifestOptions {
1763
1849
  readonly namespace: string
@@ -1771,49 +1857,56 @@ export interface CiliumEgressManifestOptions {
1771
1857
  /** `metadata.ownerReferences`. Absent ⇒ the metadata is what it always was. */
1772
1858
  readonly ownerReferences?: readonly KubernetesOwnerReference[]
1773
1859
  /**
1774
- * Expand a leading-dot entry into `matchName` plus `matchPattern` — see
1775
- * {@link ciliumFqdnEntries}. Off by default, because the config-level
1776
- * translation's emitted bytes are pinned.
1860
+ * The field a refusal for one of these hosts sends its reader to — see
1861
+ * {@link HostsFitNarrowingContext}. REQUIRED, not defaulted: there is no
1862
+ * way to tell from inside this function which config the caller's hosts
1863
+ * came from, and a refusal that names a field the caller never set is a
1864
+ * remedy that does not exist for them.
1777
1865
  *
1778
- * This option IS the translation, not a formatting flag on it: it selects
1779
- * the emitted bytes, the sentence a refusal carries and the field that
1780
- * refusal names, all from one value — see
1781
- * {@link HostsFitNarrowingContext}. There is no way to ask for one without
1782
- * the others, and none should be added.
1866
+ * The bytes are not this option's business any more, and neither is the
1867
+ * sentence a refusal uses: there is one translation of an allowlist entry,
1868
+ * so both are the same for every caller whatever this says.
1783
1869
  */
1784
- readonly expandDomains?: boolean
1870
+ readonly refusalContext: HostsFitNarrowingContext
1785
1871
  }
1786
1872
 
1787
1873
  export function buildCiliumEgressManifest(
1788
1874
  options: CiliumEgressManifestOptions,
1789
1875
  ): KubernetesTranslatedEgressPolicy {
1790
- const { narrowing, allowedHosts, expandDomains = false } = options
1791
- // Before anything is emitted. A leading-dot entry under `tlsServerNames`
1876
+ const { narrowing, allowedHosts, refusalContext } = options
1877
+ // Before anything is emitted, and for EVERY caller — which is the point:
1878
+ // an entry this backend will not translate is one whose object would be
1879
+ // either rejected on apply or, worse, accepted as a name that matches
1880
+ // nothing while the create reports success. The config-level allowlist
1881
+ // used to reach this builder unvalidated, so `['.com']`, `['.']`,
1882
+ // `['..example.com']`, `['*']` and an address were each emitted into
1883
+ // `toFQDNs` — the first three as a `matchPattern` no fence covers on that
1884
+ // path (the shipped one is scoped to the sandbox host's ServiceAccount),
1885
+ // which turned a fail-closed no-op into a silent grant of every name under
1886
+ // a public suffix. See {@link assertHostsAreUsable}.
1887
+ assertHostsAreUsable(allowedHosts)
1888
+ // A leading-dot entry under `tlsServerNames`
1792
1889
  // would become `serverNames: ['.domain']` — not a name any handshake
1793
1890
  // presents — and `['domain']` would deny every subdomain the `toFQDNs`
1794
- // half of the same rule admits; the object is admitted by the shipped
1795
- // fence and reads back deep-equal to what was sent, so nothing downstream
1796
- // would ever report it.
1891
+ // half of the same rule admits; the object goes through with nothing
1892
+ // objecting to it, and reads back deep-equal to what was sent, so nothing
1893
+ // downstream would ever report it. (On the config-level path nothing
1894
+ // objects to it at all: the shipped fence is scoped to the sandbox host's
1895
+ // ServiceAccount and an operator's apply is not matched by it.)
1797
1896
  //
1798
1897
  // What this call guarantees, per caller shape: the per-sandbox writer
1799
1898
  // refuses the same hosts earlier still, by name and by the same context
1800
1899
  // (`per-sandbox-policy.ts`), and this call covers them again if that check
1801
1900
  // is ever reached later or skipped; the config-level translation has no
1802
1901
  // earlier check of its own and relies on this one entirely; and a direct
1803
- // call to this function is covered here too, whatever its `expandDomains`.
1902
+ // call to this function is covered here too, whatever context it passes.
1804
1903
  //
1805
- // ONE input decides all of it. `expandDomains` is not a formatting flag —
1806
- // it is which translation this is, and so which field a refused caller
1807
- // actually set, which sentence is true of the entry on that path, and
1808
- // whether the translation applies the hostname grammar at all. Deriving
1809
- // the field path from anything else is how an expanding caller came to be
1810
- // sent to the config-level field for a repair that only exists under
1811
- // `perSandbox.narrowing`.
1812
- assertHostsFitNarrowing(
1813
- allowedHosts,
1814
- narrowing,
1815
- expandDomains ? PER_SANDBOX_NARROWING_REFUSAL : CONFIG_LEVEL_NARROWING_REFUSAL,
1816
- )
1904
+ // The context decides ONE thing, because one thing is left to decide —
1905
+ // which field the refusal names. It is required rather than derived, since
1906
+ // nothing here can see the caller's config, and derived from the bytes is
1907
+ // exactly how an expanding caller came to be sent to the config-level
1908
+ // field for a repair that only exists under `perSandbox.narrowing`.
1909
+ assertHostsFitNarrowing(allowedHosts, narrowing, refusalContext)
1817
1910
  // Unnarrowed is the exact shape every release before #490 emitted — kept
1818
1911
  // as its own branch, untouched, rather than folded into the narrowed one
1819
1912
  // with every option defaulted off, so the byte-identical guarantee does
@@ -1822,13 +1915,11 @@ export function buildCiliumEgressManifest(
1822
1915
  const activeDns = narrowed ? activeDnsNarrowing(narrowing.dnsNames) : undefined
1823
1916
  const dnsRule =
1824
1917
  activeDns !== undefined
1825
- ? narrowedDnsVisibilityRule(allowedHosts, options.namespace, activeDns, expandDomains)
1918
+ ? narrowedDnsVisibilityRule(allowedHosts, options.namespace, activeDns)
1826
1919
  : CILIUM_DNS_VISIBILITY_RULE
1827
- const entriesFor = (entry: string): readonly Readonly<Record<string, string>>[] =>
1828
- expandDomains ? ciliumFqdnEntries(entry) : [{ matchName: entry }]
1829
1920
  const hostRules = narrowed
1830
- ? allowedHosts.map((host) => narrowedHostFqdnRule(host, narrowing, entriesFor(host)))
1831
- : [{ toFQDNs: allowedHosts.flatMap(entriesFor) }]
1921
+ ? allowedHosts.map((host) => narrowedHostFqdnRule(host, narrowing, ciliumFqdnEntries(host)))
1922
+ : [{ toFQDNs: allowedHosts.flatMap(ciliumFqdnEntries) }]
1832
1923
 
1833
1924
  return {
1834
1925
  kind: 'CiliumNetworkPolicy',
@@ -1868,6 +1959,7 @@ function buildCiliumNetworkPolicy(
1868
1959
  allowedHosts,
1869
1960
  policyKind,
1870
1961
  ...(narrowing !== undefined ? { narrowing } : {}),
1962
+ refusalContext: CONFIG_LEVEL_NARROWING_REFUSAL,
1871
1963
  })
1872
1964
  }
1873
1965
 
@@ -1974,8 +2066,16 @@ export class KubernetesEgressPolicyMismatchError extends Error {
1974
2066
  * `../docker/index.ts`'s `assertNetworkCarriesThePolicy`, which inspects the
1975
2067
  * daemon's own `{{.Internal}}` flag instead of trusting a network's name.
1976
2068
  *
1977
- * Checks exactly three things, each named separately in a mismatch so an
1978
- * operator sees which one to fix:
2069
+ * Checks four things, each named separately in a mismatch so an operator sees
2070
+ * which one to fix:
2071
+ * - the object carries NO `specs` list. A `CiliumNetworkPolicy` carries
2072
+ * EITHER one `spec` or a `specs` list and a rule in either one enforces,
2073
+ * while every check below reads `spec` alone — so an object carrying both
2074
+ * would be compared on half of what it enforces. This is refused and not
2075
+ * read, because a comparison that accepts rules it never looked at is the
2076
+ * one answer this function must not give; the shipped admission fence
2077
+ * refuses a `specs` list for the same reason. The translation never emits
2078
+ * one, so its presence is drift and not an alternative spelling;
1979
2079
  * - the selector (`podSelector` for `NetworkPolicy`, `endpointSelector` for
1980
2080
  * `CiliumNetworkPolicy`) carries the expected template label:
1981
2081
  * - `NetworkPolicy` additionally declares `policyTypes` including
@@ -2004,6 +2104,7 @@ export async function verifyEgressPolicyApplied(
2004
2104
  let resource:
2005
2105
  | {
2006
2106
  readonly spec?: Readonly<Record<string, unknown>>
2107
+ readonly specs?: unknown
2007
2108
  readonly metadata?: Readonly<Record<string, unknown>>
2008
2109
  }
2009
2110
  | undefined
@@ -2020,6 +2121,33 @@ export async function verifyEgressPolicyApplied(
2020
2121
  const actualSpec = resource?.spec ?? {}
2021
2122
  const selectorKey = translated.kind === 'CiliumNetworkPolicy' ? 'endpointSelector' : 'podSelector'
2022
2123
 
2124
+ // First, and before anything is compared: an object carrying a `specs`
2125
+ // list is refused rather than read. Everything below reads `spec`, and a
2126
+ // `CiliumNetworkPolicy` rule in a `specs` entry enforces exactly as one in
2127
+ // `spec` does — so comparing `spec` and reporting a match would be
2128
+ // accepting rules this function never looked at, which is the one answer it
2129
+ // must not give. `readCiliumEgressPolicies` reads both spellings and
2130
+ // `decideEgressUnion` refuses a `specs` entry that widens; this is the
2131
+ // other half of the same rule, for the callers that run this check ALONE —
2132
+ // `verify: 'named-object-only'`, and the per-sandbox read-back — and it is
2133
+ // what makes the named-object exemption in `decideEgressUnion` sound rather
2134
+ // than merely narrower. The translation never emits a `specs` list, and the
2135
+ // shipped admission fence refuses one for the same reason.
2136
+ const actualSpecs = resource?.specs
2137
+ if (actualSpecs !== undefined && actualSpecs !== null) {
2138
+ throw new KubernetesEgressPolicyMismatchError(
2139
+ translated.kind,
2140
+ path,
2141
+ `specs is ${JSON.stringify(actualSpecs)}, and the translation never emits one — ${
2142
+ translated.kind === 'CiliumNetworkPolicy'
2143
+ ? 'a CiliumNetworkPolicy carries EITHER one spec or a specs list, and a rule in either one enforces'
2144
+ : 'a NetworkPolicy has no specs field at all'
2145
+ }, while this check reads spec.${selectorKey}${
2146
+ translated.kind === 'NetworkPolicy' ? ', spec.policyTypes' : ''
2147
+ } and spec.egress and nothing else — so anything a specs list enforces is egress this comparison never read`,
2148
+ )
2149
+ }
2150
+
2023
2151
  if (!isDeepStrictEqual(actualSpec[selectorKey], expectedSpec[selectorKey])) {
2024
2152
  throw new KubernetesEgressPolicyMismatchError(
2025
2153
  translated.kind,
@@ -2284,6 +2412,23 @@ export interface EgressAllowance {
2284
2412
  readonly permitsNothing: boolean
2285
2413
  /** The configured kind, for the refusal message. */
2286
2414
  readonly policyKind: KubernetesEgressPolicy['kind']
2415
+ /**
2416
+ * The object this allowance is the allowance OF — `translated.kind` and
2417
+ * `translated.name`, the object `config.egress` translates to and an
2418
+ * operator applies.
2419
+ *
2420
+ * The union rule reads every OTHER policy in the namespace against this
2421
+ * allowance, and this pair is what says which of the listed objects is the
2422
+ * named one, so {@link decideEgressUnion} can leave that object's `spec`
2423
+ * document to {@link verifyEgressPolicyApplied} instead of judging it here.
2424
+ * See {@link EgressPolicyDocument.namedObject} for what that comparison
2425
+ * reads, which documents it covers, and why the exemption is not an
2426
+ * optimisation.
2427
+ */
2428
+ readonly namedObject: {
2429
+ readonly kind: 'NetworkPolicy' | 'CiliumNetworkPolicy'
2430
+ readonly name: string
2431
+ }
2287
2432
  }
2288
2433
 
2289
2434
  function readPortRanges(ports: unknown, defaultProtocol: string | undefined): PortSet {
@@ -2418,6 +2563,7 @@ export function egressAllowance(translated: KubernetesTranslatedEgressPolicy): E
2418
2563
  permitsEverything: false,
2419
2564
  permitsNothing: false,
2420
2565
  policyKind: translated.policyKind,
2566
+ namedObject: { kind: translated.kind, name: translated.name },
2421
2567
  }
2422
2568
  }
2423
2569
  const destinations: AllowedDestination[] = []
@@ -2449,6 +2595,7 @@ export function egressAllowance(translated: KubernetesTranslatedEgressPolicy): E
2449
2595
  permitsEverything,
2450
2596
  permitsNothing: rules.length === 0,
2451
2597
  policyKind: translated.policyKind,
2598
+ namedObject: { kind: translated.kind, name: translated.name },
2452
2599
  }
2453
2600
  }
2454
2601
 
@@ -3023,6 +3170,50 @@ export interface EgressPolicyDocument {
3023
3170
  readonly rules: readonly EgressRuleVerdict[]
3024
3171
  /** Set when the OBJECT could not be read. It decides alone. */
3025
3172
  readonly unreadable?: string
3173
+ /**
3174
+ * This document is the one {@link verifyEgressPolicyApplied} compared to
3175
+ * the translation on this same create path, before this check runs: the
3176
+ * document built from the named object's `spec`, and nothing else.
3177
+ *
3178
+ * Marked so {@link decideEgressUnion} does not judge its RULES, because
3179
+ * the translation's own allowance cannot express one of them.
3180
+ * `egressAllowance` records a `toFQDNs` entry by its `matchName`, while a
3181
+ * `.domain` entry the translation emits is a `matchName` PLUS a
3182
+ * `matchPattern` — so the pattern half read as widening and the check
3183
+ * refused the very object it had just told the operator to apply,
3184
+ * permanently and on every `create()`.
3185
+ *
3186
+ * What the exempting comparison actually reads, because this marking is
3187
+ * only as sound as it is: `spec.podSelector`/`spec.endpointSelector`,
3188
+ * `spec.policyTypes` (core), `spec.egress`, and any `metadata.ownerReferences`
3189
+ * the translation carries. It is NOT a comparison of the whole object, so
3190
+ * the marking is scoped to what it covers:
3191
+ *
3192
+ * - Only the document built from `item.spec` is marked. A document built
3193
+ * from an `item.specs` entry is judged by {@link decideEgressUnion} like
3194
+ * any other object's — a rule in either spelling enforces, so a `specs`
3195
+ * entry that allows more than the translation is refused by name.
3196
+ * - {@link verifyEgressPolicyApplied} REFUSES a live object carrying a
3197
+ * `specs` list at all, which is what makes the first point airtight
3198
+ * rather than merely narrower: the translation never emits one, and half
3199
+ * of what such an object enforces would be rules that comparison never
3200
+ * read.
3201
+ *
3202
+ * What is NOT redundant, and the reason the document stays in the
3203
+ * enumeration at all, is whether it puts this pod in egress default-deny:
3204
+ * dropping it would report a deployment whose only applied policy is the
3205
+ * named one as having no boundary at all, which is a deployment that
3206
+ * verifies today.
3207
+ *
3208
+ * The cost is stated rather than hidden: the named check is memoized on
3209
+ * success for the boundary's lifetime, so a named object that drifts WIDER
3210
+ * after its own check has passed is no longer caught by this one — it is
3211
+ * caught by the named comparison at the next construction. Before this
3212
+ * exemption there was a second net under a five-minute cache; the
3213
+ * alternative is a check that refuses the object it has just told an
3214
+ * operator to apply.
3215
+ */
3216
+ readonly namedObject?: boolean
3026
3217
  }
3027
3218
 
3028
3219
  function unreadableEgressPolicy(
@@ -3040,6 +3231,36 @@ function unreadableEgressPolicy(
3040
3231
  }
3041
3232
  }
3042
3233
 
3234
+ /**
3235
+ * Is this the object `config.egress` named — see
3236
+ * {@link EgressPolicyDocument.namedObject}.
3237
+ *
3238
+ * Read off the allowance rather than passed in, so every caller of
3239
+ * {@link readCoreEgressPolicies}/{@link readCiliumEgressPolicies} that built
3240
+ * its allowance with {@link egressAllowance} gets the marking without having
3241
+ * to remember a second argument, and the readers stay a function of "(items,
3242
+ * this pod, what the translation allows)".
3243
+ *
3244
+ * For a core `NetworkPolicy` the name is the whole answer. For a
3245
+ * `CiliumNetworkPolicy` it is not: the CRD carries EITHER one `spec` or a
3246
+ * `specs` list, `verifyEgressPolicyApplied` reads the former and refuses an
3247
+ * object carrying the latter, so the reader marks only the document built
3248
+ * from `item.spec` and lets a `specs` entry be judged by the union rule like
3249
+ * any other object's rules — see {@link EgressPolicyDocument.namedObject}.
3250
+ *
3251
+ * Deliberately NOT applied to an unreadable document: an object at the named
3252
+ * name that could not be read keeps its `not-evaluable` verdict, which refuses
3253
+ * — a fail-closed answer for a shape whose exact comparison already refused it
3254
+ * earlier on the create path.
3255
+ */
3256
+ function isNamedObject(
3257
+ allowance: EgressAllowance,
3258
+ kind: EgressPolicyDocument['kind'],
3259
+ name: string,
3260
+ ): boolean {
3261
+ return allowance.namedObject.kind === kind && allowance.namedObject.name === name
3262
+ }
3263
+
3043
3264
  /** Every core `NetworkPolicy` in the list, reduced to {@link EgressPolicyDocument}. */
3044
3265
  export function readCoreEgressPolicies(
3045
3266
  items: readonly unknown[],
@@ -3080,6 +3301,7 @@ export function readCoreEgressPolicies(
3080
3301
  return {
3081
3302
  kind: 'NetworkPolicy',
3082
3303
  name,
3304
+ ...(isNamedObject(allowance, 'NetworkPolicy', name) ? { namedObject: true } : {}),
3083
3305
  selects: matchesLabelSelector(spec.podSelector, target.podLabels),
3084
3306
  enforcesEgress,
3085
3307
  // An `egress` block under a `policyTypes` that leaves Egress out is
@@ -3113,8 +3335,18 @@ export function readCiliumEgressPolicies(
3113
3335
  }
3114
3336
  // The CRD carries EITHER one `spec` or a `specs` list, and a rule in
3115
3337
  // either enforces. Reading only `spec` would miss a whole policy.
3116
- const specs: unknown[] = []
3117
- if (item.spec !== undefined && item.spec !== null) specs.push(item.spec)
3338
+ //
3339
+ // The two spellings are NOT equal where the named-object exemption is
3340
+ // concerned — see {@link EgressPolicyDocument.namedObject}. The exact
3341
+ // comparison the exemption leans on reads `spec` and refuses an object
3342
+ // carrying a `specs` list, so `spec` is the one document that earns the
3343
+ // marking; a `specs` entry is read as an ordinary policy's rules and is
3344
+ // judged by the union rule, which is what refuses one that widens.
3345
+ const named = isNamedObject(allowance, 'CiliumNetworkPolicy', name)
3346
+ const specDocuments: Array<{ readonly spec: unknown; readonly isTheSpec: boolean }> = []
3347
+ if (item.spec !== undefined && item.spec !== null) {
3348
+ specDocuments.push({ spec: item.spec, isTheSpec: true })
3349
+ }
3118
3350
  const more = readList(item.specs)
3119
3351
  if (more === 'unreadable') {
3120
3352
  documents.push(
@@ -3126,27 +3358,36 @@ export function readCiliumEgressPolicies(
3126
3358
  )
3127
3359
  continue
3128
3360
  }
3129
- for (const spec of more ?? []) specs.push(spec)
3130
- if (specs.length === 0) {
3361
+ for (const spec of more ?? []) specDocuments.push({ spec, isTheSpec: false })
3362
+ if (specDocuments.length === 0) {
3131
3363
  documents.push(
3132
3364
  unreadableEgressPolicy('CiliumNetworkPolicy', name, 'neither a spec nor a specs list'),
3133
3365
  )
3134
3366
  continue
3135
3367
  }
3136
- for (const spec of specs) {
3137
- const document = readCiliumEgressRuleSpec(spec, name, identity, allowance)
3368
+ for (const { spec, isTheSpec } of specDocuments) {
3369
+ const document = readCiliumEgressRuleSpec(spec, name, identity, allowance, named && isTheSpec)
3138
3370
  if (document !== undefined) documents.push(document)
3139
3371
  }
3140
3372
  }
3141
3373
  return documents
3142
3374
  }
3143
3375
 
3144
- /** One `spec`/`specs` entry. `undefined` when it is node-scoped — see below. */
3376
+ /**
3377
+ * One `spec`/`specs` entry. `undefined` when it is node-scoped — see below.
3378
+ *
3379
+ * `isTheNamedSpec` is true only for the document built from the named
3380
+ * object's own `spec` — the one {@link verifyEgressPolicyApplied} compared to
3381
+ * the translation. A `specs` entry never is; see
3382
+ * {@link EgressPolicyDocument.namedObject} for why that distinction is
3383
+ * load-bearing rather than bookkeeping.
3384
+ */
3145
3385
  function readCiliumEgressRuleSpec(
3146
3386
  spec: unknown,
3147
3387
  name: string,
3148
3388
  identity: Readonly<Record<string, string>>,
3149
3389
  allowance: EgressAllowance,
3390
+ isTheNamedSpec: boolean,
3150
3391
  ): EgressPolicyDocument | undefined {
3151
3392
  const unreadable = (detail: string) => unreadableEgressPolicy('CiliumNetworkPolicy', name, detail)
3152
3393
  if (!isRecord(spec)) return unreadable('a rule spec that is not an object')
@@ -3171,6 +3412,7 @@ function readCiliumEgressRuleSpec(
3171
3412
  return {
3172
3413
  kind: 'CiliumNetworkPolicy',
3173
3414
  name,
3415
+ ...(isTheNamedSpec ? { namedObject: true } : {}),
3174
3416
  selects: matchesLabelSelector(spec.endpointSelector, identity, ciliumSelectorKey),
3175
3417
  enforcesEgress,
3176
3418
  // `egressDeny` rules are not read: a deny rule can only narrow what
@@ -3203,6 +3445,15 @@ export interface EgressUnionDecision {
3203
3445
  * translation is the finding however many narrower ones sit beside it, and a
3204
3446
  * pod no policy default-denies has no egress boundary at all whatever the
3205
3447
  * named object says.
3448
+ *
3449
+ * The named object's `spec` is the one document whose rules are not judged
3450
+ * here — see {@link EgressPolicyDocument.namedObject} — and it was compared to
3451
+ * the translation by {@link verifyEgressPolicyApplied} on the same create path
3452
+ * just before. Its SELECTOR and its egress-scoping still decide, which is what
3453
+ * keeps "nothing bounds this pod" answerable for a deployment whose only
3454
+ * policy is that one; and a document built from a `specs` entry is judged here
3455
+ * like any other object's, because the exempting comparison refuses an object
3456
+ * carrying a `specs` list rather than reading one.
3206
3457
  */
3207
3458
  export function decideEgressUnion(
3208
3459
  documents: readonly EgressPolicyDocument[],
@@ -3239,27 +3490,39 @@ export function decideEgressUnion(
3239
3490
  undecided ??= entry
3240
3491
  continue
3241
3492
  }
3242
- const beyondRule = document.rules.find((rule) => rule.beyond === true)
3243
- if (beyondRule !== undefined) {
3244
- const entry: ExaminedEgressPolicy = {
3245
- ...base,
3246
- verdict: 'widens-egress',
3247
- ...(beyondRule.detail !== undefined ? { detail: beyondRule.detail } : {}),
3493
+ // The named object's `spec` rules were just compared to the translation
3494
+ // by `verifyEgressPolicyApplied`, field by field, which is the one
3495
+ // judgement about them that a `toFQDNs`-by-`matchName` allowance cannot
3496
+ // reproduce — see {@link EgressPolicyDocument.namedObject}. So the union
3497
+ // rule does not judge them; judging them here is what made the check
3498
+ // refuse the object it had just told an operator to apply. Everything
3499
+ // below still applies, which is how a pod whose only policy is the named
3500
+ // one is still known to be in egress default-deny. Only that `spec`
3501
+ // document carries the marking: a `specs` entry is judged here like any
3502
+ // other rules.
3503
+ if (document.namedObject !== true) {
3504
+ const beyondRule = document.rules.find((rule) => rule.beyond === true)
3505
+ if (beyondRule !== undefined) {
3506
+ const entry: ExaminedEgressPolicy = {
3507
+ ...base,
3508
+ verdict: 'widens-egress',
3509
+ ...(beyondRule.detail !== undefined ? { detail: beyondRule.detail } : {}),
3510
+ }
3511
+ examined.push(entry)
3512
+ widening ??= entry
3513
+ continue
3248
3514
  }
3249
- examined.push(entry)
3250
- widening ??= entry
3251
- continue
3252
- }
3253
- const unknownRule = document.rules.find((rule) => rule.beyond === 'unknown')
3254
- if (unknownRule !== undefined) {
3255
- const entry: ExaminedEgressPolicy = {
3256
- ...base,
3257
- verdict: 'not-evaluable',
3258
- ...(unknownRule.detail !== undefined ? { detail: unknownRule.detail } : {}),
3515
+ const unknownRule = document.rules.find((rule) => rule.beyond === 'unknown')
3516
+ if (unknownRule !== undefined) {
3517
+ const entry: ExaminedEgressPolicy = {
3518
+ ...base,
3519
+ verdict: 'not-evaluable',
3520
+ ...(unknownRule.detail !== undefined ? { detail: unknownRule.detail } : {}),
3521
+ }
3522
+ examined.push(entry)
3523
+ undecided ??= entry
3524
+ continue
3259
3525
  }
3260
- examined.push(entry)
3261
- undecided ??= entry
3262
- continue
3263
3526
  }
3264
3527
  if (!document.enforcesEgress) {
3265
3528
  examined.push({
@@ -3379,7 +3642,12 @@ function formatEgressRemedy(
3379
3642
  * Verify-not-trust, widened from one object to the union: list the
3380
3643
  * namespace's policies, evaluate every one that selects this pod against the
3381
3644
  * configured translation, and refuse unless nothing lets out more than
3382
- * `config.egress` says.
3645
+ * `config.egress` says. The named object's `spec` document is the one
3646
+ * exception, and it was compared to the translation by
3647
+ * {@link verifyEgressPolicyApplied} on this same create path just before this
3648
+ * one — see {@link EgressPolicyDocument.namedObject}: a Cilium object that
3649
+ * carries a `specs` list is refused there outright, so a `specs` entry is
3650
+ * judged HERE, like any other object's rules.
3383
3651
  *
3384
3652
  * Runs beside the ingress check on every create path — before the POST for a
3385
3653
  * directly created Sandbox, where the labels are known and a refusal leaves