diff --git a/batten.toml b/batten.toml index 3aada79e3..09619255c 100644 --- a/batten.toml +++ b/batten.toml @@ -7897,8 +7897,24 @@ body_template = ".github/bot-lane-row.md" # `[[provision.env]]` no-proxy list above. Pointed at a route the proxy carries, # the control arm fails and the engine reports could-not-look rather than # inventing a verdict. +# +# `names` IS THIS REPOSITORY'S FACT AND WAS AN ENGINE LITERAL FOR ONE COMMIT +# (CLOUD-1615). `crates/batten` carried `["GITHUB_PERSONAL_ACCESS_TOKEN", +# "BATTEN_GITHUB_TOKEN"]`, which is rule 1: a consumer on any other forge, or on +# a host injecting under a third name, finds neither variable set, so every +# candidate is absent, the verdict is `Unusable`, and every removal above is +# skipped FOREVER. That failure is invisible, because skipping a removal is also +# the right answer for a genuinely bad credential. +# +# THE SAME TWO NAMES THE ROWS ABOVE PREFER, and no others. `GITHUB_TOKEN` and +# `GH_TOKEN` are deliberately absent even though tools read them: this container +# injects a `proxy-`-prefixed marker under both, so probing them would measure +# the host's substituted credential rather than one we hold — the exact +# conflation this whole mechanism exists to undo. They stay in `from_first_set` +# as last resorts and stay out of here as evidence. [credential] probe_url = "https://api.github.com/rate_limit" +names = ["GITHUB_PERSONAL_ACCESS_TOKEN", "BATTEN_GITHUB_TOKEN"] [attribution] # Authorship. Both fields are judged: a repair reaching only `author` leaves the diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 3865c165d..3aca33bd1 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -1936,7 +1936,14 @@ fn binary_is_behind_the_config(source: &str, text: &str) -> bool { let Some(root) = Path::new(source).parent() else { return false; }; - let at = root.join("schema").join("batten.schema.json"); + // [`SCHEMA_PATH`], never a second spelling of it. It was hand-joined here + // for one commit, which is the two-authorities shape: the constant exists + // precisely because more than one reader needs the location, and a reader + // that spells its own is the one that keeps working after the constant + // moves. It is batten's own convention rather than a consumer identifier — + // the same class as [`CONFIG_FILE`] — so the fix is the constant, not + // config. + let at = root.join(SCHEMA_PATH); let (Ok(committed), Ok(derived)) = (fs::read_to_string(&at), schema()) else { return false; }; diff --git a/crates/batten/src/provision.rs b/crates/batten/src/provision.rs index e9bc3393e..6533d7f7f 100644 --- a/crates/batten/src/provision.rs +++ b/crates/batten/src/provision.rs @@ -351,6 +351,25 @@ pub struct CredentialProbe { /// it cannot reach. #[serde(default, skip_serializing_if = "Option::is_none")] pub probe_url: Option, + /// The variables a session credential may arrive under, most specific first. + /// + /// **Declared here rather than listed in the engine, which is non-negotiable + /// rule 1.** It was two GitHub-shaped literals in `crates/batten` for one + /// commit, and a consumer on another forge — or on a host that injects under + /// a third name — has neither: every candidate is then absent, + /// [`credential_health`] answers [`Credential::Unusable`], and every removal + /// is silently skipped forever. That failure is INVISIBLE, because skipping + /// a removal is also the correct behaviour when a credential is genuinely + /// bad, so the dead path and the working path look identical from outside. + /// + /// Which names belong here is a judgement only the consumer can make. A host + /// may inject a substitutable placeholder under the forge's conventional + /// names, so probing those would measure the host's credential rather than + /// one we hold — the exact conflation this mechanism exists to undo, and the + /// reason this list is deliberately not "every variable that looks like a + /// token". Naming none is could-not-look, not health: it authorises nothing. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub names: Vec, } /// Whether the credential this container was given actually works. @@ -1075,20 +1094,25 @@ fn attribute_text(value: &x509_cert::der::Any) -> Option { .ok() } -/// The endpoint a credential is proved against, from the consumer's config. +/// What a credential is proved against and where one is looked for, from the +/// consumer's config. /// -/// **Config rather than an engine literal, which is non-negotiable rule 1.** The +/// **Config rather than engine literals, which is non-negotiable rule 1.** The /// engine holds the MECHANISM — refuse a known-bad credential, then test the -/// real one — and the forge whose credential this is belongs to the repository -/// that holds it. A hostname baked in here would be a consumer identifier in -/// `crates/batten`, and would be simply wrong for a consumer on another forge. +/// real one — and both facts it needs belong to the repository that holds the +/// credential: the endpoint, because a hostname here would be simply wrong for a +/// consumer on another forge, and the variable NAMES, because a list here is +/// silently dead everywhere the host spells them differently. +/// **Read ONCE, for both.** It was two calls over the same file — one per +/// candidate for the URL, none at all for the names — and a second reader of one +/// config is a place two answers can disagree. /// **EVERY STEP REPORTS ITS OWN FAILURE.** Written first as one `?` chain over /// `.ok()`, which collapsed four different could-not-looks into `None` and made /// the launcher's report say "no probe declared" over a repository that declares /// one — the same conflation [`Look`] exists to refuse, reintroduced one /// function up. A caller cannot act on the answer without knowing which step /// gave it. -fn credential_probe_url() -> std::result::Result { +fn credential_declaration() -> std::result::Result { let cwd = std::env::current_dir().map_err(|err| format!("no working directory: {err}"))?; let root = crate::git::repo_root(&cwd) .map_err(|err| format!("{} is in no repository: {err}", cwd.display()))?; @@ -1102,9 +1126,8 @@ fn credential_probe_url() -> std::result::Result { crate::config::load(&at).map_err(|err| format!("{} will not load: {err}", at.display()))?; config .credential - .as_ref() - .and_then(|probe| probe.probe_url.clone()) - .ok_or_else(|| "no `[credential] probe_url` is declared".to_owned()) + .clone() + .ok_or_else(|| "no `[credential]` table is declared".to_owned()) } /// Where a credential verdict is cached, keyed by the credential's DIGEST. @@ -1140,7 +1163,7 @@ const CREDENTIAL_MAX_AGE: u64 = 300; /// Failure to look is `false`, and the direction is deliberate: an unprovable /// candidate is skipped rather than trusted, and if NO candidate proves usable /// the caller keeps the host's own wiring instead of stripping it. -fn credential_usable(value: &Secret) -> bool { +fn credential_usable(value: &Secret, probe: &str) -> bool { let Some(receipt) = credential_receipt_path(value) else { return false; }; @@ -1156,22 +1179,17 @@ fn credential_usable(value: &Secret) -> bool { // ORDER IS THE WHOLE DESIGN: establish the route can REFUSE before believing // that it accepted. Reversed, a substituting route reports every credential // live — including one that has been revoked. - let verdict = match credential_probe_url() { - // No declared endpoint is could-not-look, not health. Reading it as - // healthy would authorise stripping the host's wiring on a consumer that - // never said how to check. - Err(why) => Verdict::NoProbe(why), - Ok(probe) => match route_honours_credentials(&probe) { - // Nobody could reach it. The reason is already on stderr. - None => Verdict::Unreachable, - // The route answered for a token of zeroes, so it is answering with - // an identity of its own and nothing it says about ours is evidence. - Some(false) => Verdict::RouteSubstitutes, - Some(true) => match probe_credential(&probe, value) { - Look::Answered(true) => Verdict::Live, - Look::Answered(false) => Verdict::Refused, - Look::CouldNotLook(_) => Verdict::Unreachable, - }, + let verdict = match route_honours_credentials(probe, value) { + // Nobody could reach it. The reason is already on stderr. + None => Verdict::Unreachable, + // The route answered for a token that cannot be this credential, so it + // is answering with an identity of its own and nothing it says about + // ours is evidence. + Some(false) => Verdict::RouteSubstitutes, + Some(true) => match probe_credential(probe, value) { + Look::Answered(true) => Verdict::Live, + Look::Answered(false) => Verdict::Refused, + Look::CouldNotLook(_) => Verdict::Unreachable, }, }; let live = verdict == Verdict::Live; @@ -1197,29 +1215,54 @@ fn credential_usable(value: &Secret) -> bool { live } -/// The names a session credential may arrive under, most specific first. -/// -/// `GITHUB_TOKEN`/`GH_TOKEN` are deliberately absent: a host may inject a -/// substitutable placeholder under those, so probing them would measure the -/// host's own credential rather than one we hold — the exact conflation this -/// whole mechanism exists to undo. -const CREDENTIAL_NAMES: [&str; 2] = ["GITHUB_PERSONAL_ACCESS_TOKEN", "BATTEN_GITHUB_TOKEN"]; - /// Whether ANY declared credential is usable, which is what gates every removal. /// /// Separate from per-candidate selection because the two ask different /// questions: selection asks "which value do I write", this asks "have I proved /// a replacement exists at all" — and only the second may authorise stripping /// the host's proxy wiring. +/// +/// **Every could-not-look is [`Credential::Unusable`] AND IS SAID OUT LOUD.** +/// An undeclared table, an undeclared endpoint and an undeclared name list all +/// reach the same verdict as a revoked token, and they are not the same +/// situation: one is the operator's credential to replace and the others are the +/// consumer's config to write. Reporting the distinction is the only thing +/// separating this from a mechanism that loads clean and decides nothing. fn credential_health() -> Credential { - if CREDENTIAL_NAMES + let declaration = match credential_declaration() { + Ok(declaration) => declaration, + // No declared table is could-not-look, not health. Reading it as healthy + // would authorise stripping the host's wiring on a consumer that never + // said how to check. + Err(why) => { + crate::config::report(&Verdict::NoProbe(why).why()); + return Credential::Unusable; + } + }; + let Some(probe) = declaration.probe_url.as_deref() else { + crate::config::report( + &Verdict::NoProbe("no `[credential] probe_url` is declared".to_owned()).why(), + ); + return Credential::Unusable; + }; + if declaration.names.is_empty() { + crate::config::report( + &Verdict::NoProbe( + "`[credential] names` is empty, so there is no variable to look in".to_owned(), + ) + .why(), + ); + return Credential::Unusable; + } + if declaration + .names .iter() // Into a `Secret` at the READ, which is the only place that keeps the // window shut: a value bound to a `String` first is a value some later // edit can print, and the whole class this fixes is later edits. .filter_map(|name| std::env::var(name).ok().map(Secret::new)) .filter(|value| !value.is_empty()) - .any(|value| credential_usable(&value)) + .any(|value| credential_usable(&value, probe)) { Credential::Live } else { @@ -1246,12 +1289,13 @@ fn credential_health() -> Credential { /// collapsing them is what made this mechanism's first live reading unreadable: /// every candidate resolved unusable, and the report could not say whether the /// forge had refused the token or the connection had never been made. -fn route_honours_credentials(probe: &str) -> Option { - // Syntactically plausible so the refusal is about the CREDENTIAL rather than - // about malformed input, which a route could reject without ever consulting - // an identity — and that would read as honesty it has not demonstrated. - const MUST_FAIL: &str = "ghp_0000000000000000000000000000000000"; - match probe_credential(probe, &Secret::new(MUST_FAIL.to_owned())) { +/// +/// It takes the REAL credential to derive a known-bad one shaped like it — see +/// [`known_bad_like`] — and never to send it. The control arm must be spent per +/// candidate rather than once, because "shaped like it" is only meaningful +/// against a particular credential. +fn route_honours_credentials(probe: &str, real: &Secret) -> Option { + match probe_credential(probe, &known_bad_like(real)) { Look::Answered(accepted) => Some(!accepted), Look::CouldNotLook(why) => { crate::config::report(&format!( @@ -1262,6 +1306,84 @@ fn route_honours_credentials(probe: &str) -> Option { } } +/// The character a derived body is filled with, and the one it falls back to. +/// +/// Two, because the derivation is only known-bad if it DIFFERS from the value it +/// was derived from — and a credential whose body is already all `0` would +/// derive to itself, making a live route look like a substituting one. That +/// direction is safe (removals are skipped) but it is still a wrong answer, and +/// ruling it out costs one comparison. +const FILL: [char; 2] = ['0', '1']; + +/// A credential the route must REFUSE, shaped like the one we are about to test. +/// +/// # Why derived rather than a literal +/// +/// This was one forge's token prefix followed by a run of zeroes, spelled out in +/// `crates/batten`, for one commit — non-negotiable rule 1 twice over: a forge's +/// token prefix is a consumer identifier, and the literal is simply WRONG off +/// that forge. (The spelling is not repeated here; the gate that now enforces +/// this, `the_engine_names_no_consumer_of_its_own`, reads THIS file, and a +/// scanner whose own corpus carries the shape it hunts is one nobody can trust a +/// negative from. It caught this paragraph's first draft.) The doc it sat under +/// stated the criterion it broke — the token must be *"syntactically plausible +/// so the refusal is about the CREDENTIAL rather than about malformed input, +/// which a route could reject without ever consulting an identity"* — and that +/// prefix **is** malformed input to a GitLab, Gitea or Bitbucket endpoint. There the control +/// arm passes for exactly the reason the criterion rules out, so the route is +/// declared to honour credentials on evidence it has not earned, and every +/// verdict downstream of it rests on a rubber stamp. +/// +/// Plausibility is a property of a FORGE, and an agnostic engine holds no +/// forge's grammar. What it does hold is a real credential for that forge, and +/// the shape of a well-formed token is the best available evidence of what a +/// well-formed token looks like there. So the derivation keeps everything up to +/// and including the last structural (non-alphanumeric) character — a prefix +/// like `ghp_` or `glpat-`, whatever this forge spells — and refills the body, +/// preserving length and character class. +/// +/// # Why the consumer does not get to name it +/// +/// Deliberately not a config field. A consumer that could name the known-bad +/// could name one the route happens to ACCEPT, which turns the control arm into +/// the rubber stamp it exists to prevent. The engine derives it, so the claim +/// "this route refuses a bad credential" is one nothing outside can weaken. +/// +/// # What it is not +/// +/// Not a guarantee of invalidity. A forge could in principle have issued the +/// derived value, at a probability no operator needs to reason about; if it had, +/// the route would accept it, the verdict would read [`Verdict::RouteSubstitutes`], +/// and removals would be SKIPPED — the safe direction, which is why this is +/// stated rather than defended against. +fn known_bad_like(real: &Secret) -> Secret { + // `expose` here and nowhere downstream: the derived value is what travels, + // and it is a `Secret` too, so a report that renders it says `` + // rather than handing a reader a token-shaped string to mistake for ours. + let real = real.expose(); + let body_from = real + .char_indices() + .rfind(|(_, char)| !char.is_alphanumeric()) + .map_or(0, |(at, char)| at + char.len_utf8()); + let mut derived = String::with_capacity(real.len()); + for fill in FILL { + derived.clear(); + derived.push_str(&real[..body_from]); + // Length AND class preserved: one filler char per body char, so a route + // that checks either sees a token it has to consult an identity about. + derived.extend(real[body_from..].chars().map(|_| fill)); + if derived != real { + return Secret::new(derived); + } + } + // A value with NO alphanumeric body — every character structural — has + // nothing to refill, so both fills derive it back. Lengthening it is the one + // remaining way to differ, and a credential of that shape is not one any + // forge issued anyway. + derived.push(FILL[0]); + Secret::new(derived) +} + /// Why a credential did or did not prove usable. /// /// **FOUR WAYS TO FAIL, AND THEY HAVE DIFFERENT REMEDIES**, which is the whole @@ -1887,6 +2009,69 @@ pub fn binary_path(repo_root: &Path, entry: &Provision) -> Result { mod tests { use super::*; + // ----------------------------------------------------------------------- + // The control credential is DERIVED, never a forge's literal (CLOUD-1615). + // + // Shaped like the value it stands in for, on whatever forge that is, so the + // route has to consult an identity to refuse it. A case here asserts the + // three properties that makes checkable — prefix, length, difference — + // rather than a rendering, because the value never reaches a rendering. + // ----------------------------------------------------------------------- + + /// Distinctive, and DELIBERATELY NOT SHAPED LIKE A REAL CREDENTIAL: every + /// assertion below is over structure, so a well-formed fixture would buy + /// nothing and would put a token-shaped string in front of the scanner. + const CANARY: &str = "xyz_CANARYnotacredential99"; + + #[test] + fn the_control_credential_keeps_the_structural_prefix() { + let derived = known_bad_like(&Secret::new(CANARY.to_owned())); + assert!( + derived.starts_with("xyz_"), + "the forge's own prefix is what makes the token plausible THERE, and \ + an engine that dropped it would be sending malformed input — which a \ + route can refuse without ever consulting an identity" + ); + } + + /// The engine holds no forge's grammar, so the case proves the derivation + /// travels: a prefix it has never seen is carried exactly as `xyz_` is. + #[test] + fn it_carries_a_prefix_the_engine_has_never_seen() { + let derived = known_bad_like(&Secret::new("glpat-AbCdEfGhIjKlMnOp".to_owned())); + assert!(derived.starts_with("glpat-"), "another forge's prefix"); + assert_eq!(derived.expose().len(), "glpat-AbCdEfGhIjKlMnOp".len()); + } + + #[test] + fn the_control_credential_preserves_length_and_differs() { + let real = Secret::new(CANARY.to_owned()); + let derived = known_bad_like(&real); + assert_eq!( + derived.expose().len(), + CANARY.len(), + "a length check is the cheapest thing a route can reject on without \ + consulting an identity" + ); + assert_ne!( + derived.expose(), + real.expose(), + "A CONTROL THAT IS THE CREDENTIAL PROVES NOTHING: the route would \ + accept it for the right reason and be read as substituting" + ); + } + + /// SHOWN ABLE TO FAIL in the degenerate direction (CLOUD-418). A body that + /// is already the first filler derives to itself unless the fallback fires, + /// which is the one input where the property above is not free. + #[test] + fn a_credential_already_made_of_the_filler_still_differs() { + for real in ["tok_000000", "0000", "----"] { + let derived = known_bad_like(&Secret::new(real.to_owned())); + assert_ne!(derived.expose(), real, "over {real}"); + } + } + /// A row that sets `name` from `from_first_set`, rejecting `proxy-`. fn credential_row(name: &str) -> ProvisionEnv { ProvisionEnv { diff --git a/crates/batten/tests/it/provision.rs b/crates/batten/tests/it/provision.rs index 63f827a93..4ae6f8428 100644 --- a/crates/batten/tests/it/provision.rs +++ b/crates/batten/tests/it/provision.rs @@ -1280,3 +1280,155 @@ fn an_unreadable_trust_bundle_does_not_apply_the_bypass() { "a bundle that cannot be read is not evidence of an interceptor: {seen}" ); } + +// --------------------------------------------------------------------------- +// (g) THE CORE STAYS REPO-AGNOSTIC, AS A GATE (CLOUD-1615). +// +// Non-negotiable rule 1 was held by convention and by review, and measured +// 2026-09-07 the convention had failed three times in one commit range — all +// three by the same route, and all three invisible for the same reason: **each +// one worked perfectly when it was tested in this repository, on this forge, in +// this container.** A grep for a specific consumer's names is what the rule +// itself asks for, so non-negotiable rule 2 makes it a runnable check rather +// than a paragraph. +// +// It scans SOURCE, which is the only surface where the defect is visible: a +// behavioural case would need a second forge to fail against, and there is not +// one. Pointer-only (rule 4): a finding is `path:line` and the fragment class, +// never the matched line. +// --------------------------------------------------------------------------- + +/// Fragments assembled at runtime, and that is not decoration. +/// +/// Writing the forbidden literal here would put it in `crates/batten` — under +/// `tests/`, which this scan does not read, but a scanner whose own corpus +/// carries the shape it hunts is one nobody can trust the negative from. Split +/// halves also survive the day somebody widens the scan to the whole crate. +fn forbidden_fragments() -> Vec<(String, &'static str)> { + vec![ + ( + format!("{}{}", "GITHUB_PERSONAL", "_ACCESS_TOKEN"), + "a forge-specific credential variable — declare it in `[credential] names`", + ), + ( + format!("{}{}", "BATTEN_GITHUB", "_TOKEN"), + "a host-specific credential variable — declare it in `[credential] names`", + ), + ( + format!("{}{}", "ghp", "_0000"), + "a forge-specific token literal — derive the control credential from the real one", + ), + ] + // DELIBERATELY NOT `schema/batten.schema.json`, and the withdrawal is worth + // recording. It was inventoried as a third violation on the reading that the + // engine was reaching for a path only this repository generates. It is not: + // that path is BATTEN's own convention, the same class as `batten.toml` + // itself, and no consumer identifier appears in it. The real defect at that + // site was a hand-joined second spelling of `config::SCHEMA_PATH` — two + // authorities for one location — which is fixed there rather than gated + // here, because a ban on the constant would ban the convention. +} + +/// Every `.rs` under `crates/batten/src`, which is what rule 1 binds. +/// +/// `src` alone, deliberately: `tests/` is this repository's own corpus and is +/// entitled to name this repository, which is why the fragments above are +/// assembled rather than spelled. +fn engine_sources() -> Vec { + fn walk(at: &Path, into: &mut Vec) { + let Ok(entries) = fs::read_dir(at) else { + return; + }; + let mut paths: Vec = entries.flatten().map(|entry| entry.path()).collect(); + // Sorted so a finding is reported at the same place on every run, which + // is what makes the output byte-stable (house style §6). + paths.sort(); + for path in paths { + if path.is_dir() { + walk(&path, into); + } else if path.extension().is_some_and(|ext| ext == "rs") { + into.push(path); + } + } + } + let mut found = Vec::new(); + walk( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), + &mut found, + ); + found +} + +/// `path:line why` for every forbidden fragment in `text`. +fn findings_in(path: &Path, text: &str, fragments: &[(String, &'static str)]) -> Vec { + let mut found = Vec::new(); + for (number, line) in text.lines().enumerate() { + for (fragment, why) in fragments { + if line.contains(fragment.as_str()) { + found.push(format!("{}:{} {why}", path.display(), number + 1)); + } + } + } + found +} + +/// THE PREMISE (CLOUD-249's shape). The case below asserts an ABSENCE, and an +/// absence passes vacuously if the scan could never have found anything — an +/// empty file list, a misassembled fragment. So one case proves the instrument +/// works, over a body that genuinely carries every fragment. +#[test] +fn the_agnostic_scan_would_find_a_violation() { + let fragments = forbidden_fragments(); + assert!( + !engine_sources().is_empty(), + "an empty corpus makes the absence below vacuous" + ); + for (fragment, _) in &fragments { + let planted = format!("let x = \"{fragment}\";"); + assert_eq!( + findings_in(Path::new("planted.rs"), &planted, &fragments).len(), + 1, + "the scan must find {fragment} when it IS there, or its absence \ + below says nothing" + ); + } +} + +#[test] +fn the_engine_names_no_consumer_of_its_own() { + let fragments = forbidden_fragments(); + let mut findings = Vec::new(); + for path in engine_sources() { + let text = fs::read_to_string(&path).expect("readable source"); + findings.extend(findings_in(&path, &text, &fragments)); + } + assert!( + findings.is_empty(), + "non-negotiable rule 1: the core stays repo-agnostic, and a consumer's \ + fact belongs in that consumer's own `batten.toml`:\n{}", + findings.join("\n") + ); +} + +/// The other half of the same fix: the names the engine no longer holds are +/// declared, and the type carries them. +/// +/// A field nothing reads is the dead-gate shape one layer down, so the case +/// asserts the round trip rather than the declaration. +#[test] +fn the_credential_table_carries_the_names_the_engine_dropped() { + let declared: batten::provision::CredentialProbe = toml::from_str( + "probe_url = \"https://example.invalid/probe\"\nnames = [\"A_TOKEN\", \"B_TOKEN\"]\n", + ) + .expect("the `[credential]` table parses"); + assert_eq!(declared.names, vec!["A_TOKEN", "B_TOKEN"]); + + // AN ABSENT LIST IS COULD-NOT-LOOK, NOT AN ERROR. A consumer that declares + // a probe and no names still loads — the engine reports and authorises no + // removal — because refusing the config would make a launcher that cannot + // start over a field it could have reported. + let bare: batten::provision::CredentialProbe = + toml::from_str("probe_url = \"https://example.invalid/probe\"\n") + .expect("names is optional"); + assert!(bare.names.is_empty()); +} diff --git a/schema/batten.schema.json b/schema/batten.schema.json index a3db98fe1..941f467e1 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -1044,6 +1044,13 @@ "description": "How a credential is proved usable, declared by the consumer.\n\n**One field, and the control credential is NOT one of them.** A caller does\nnot get to choose what \"known bad\" means: the engine sends a syntactically\nplausible token that must be refused, because a consumer that could name it\ncould name one the route happens to accept and turn the control arm into a\nrubber stamp.", "type": "object", "properties": { + "names": { + "description": "The variables a session credential may arrive under, most specific first.\n\n**Declared here rather than listed in the engine, which is non-negotiable\nrule 1.** It was two GitHub-shaped literals in `crates/batten` for one\ncommit, and a consumer on another forge — or on a host that injects under\na third name — has neither: every candidate is then absent,\n[`credential_health`] answers [`Credential::Unusable`], and every removal\nis silently skipped forever. That failure is INVISIBLE, because skipping\na removal is also the correct behaviour when a credential is genuinely\nbad, so the dead path and the working path look identical from outside.\n\nWhich names belong here is a judgement only the consumer can make. A host\nmay inject a substitutable placeholder under the forge's conventional\nnames, so probing those would measure the host's credential rather than\none we hold — the exact conflation this mechanism exists to undo, and the\nreason this list is deliberately not \"every variable that looks like a\ntoken\". Naming none is could-not-look, not health: it authorises nothing.", + "type": "array", + "items": { + "type": "string" + } + }, "probe_url": { "description": "The endpoint that answers 2xx for a good credential and refuses a bad\none.\n\nIt must be a route the fence sends DIRECT. Pointed at one an intercepting\nproxy carries, the control arm fails — the junk credential is accepted —\nand the engine reports could-not-look rather than pretending to a verdict\nit cannot reach.", "type": [