Skip to main content

harn_vm/
environment_registry.rs

1//! Authoritative registry and startup validation for Harn-owned environment
2//! variables.
3//!
4//! Readers remain at their owning boundaries, where values have the context
5//! needed for full validation. This module owns names, coarse value shapes,
6//! sensitivity metadata, extension policy, and unknown-name diagnostics. The
7//! registry drift test keeps production readers from creating an unregistered
8//! parallel namespace.
9
10use std::ffi::{OsStr, OsString};
11use std::fmt;
12
13const REGISTERED_NAMES: &str = include_str!("environment_registry_names.txt");
14const UNKNOWN_CODE: &str = "HARN-ENV-001";
15const INVALID_VALUE_CODE: &str = "HARN-ENV-002";
16
17/// The subsystem that owns an environment variable.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum EnvironmentConsumer {
20    Runtime,
21    Cli,
22    BuildTooling,
23    TestHarness,
24    EmbedderExtension,
25}
26
27/// The shape enforced at startup, before the owning reader sees the value.
28///
29/// `OwnerValidated` means the value needs domain context and remains validated
30/// by its consumer. The registry still owns and checks the name.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum EnvironmentValueShape {
33    OwnerValidated,
34    Boolean,
35    UnsignedInteger,
36    NonNegativeNumber,
37    UnitInterval,
38    Enumerated(&'static [&'static str]),
39}
40
41/// Whether a value may contain credential material.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum EnvironmentSensitivity {
44    Public,
45    Credential,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct EnvironmentVariableSpec {
50    pub name: String,
51    pub consumer: EnvironmentConsumer,
52    pub value_shape: EnvironmentValueShape,
53    pub sensitivity: EnvironmentSensitivity,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub enum EnvironmentDiagnosticKind {
58    UnknownName { suggestion: Option<String> },
59    InvalidValue { expected: EnvironmentValueShape },
60}
61
62/// A key-only diagnostic. Values are intentionally absent from the type, so
63/// rendering cannot accidentally disclose credential material.
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct EnvironmentDiagnostic {
66    pub code: &'static str,
67    pub key: String,
68    pub kind: EnvironmentDiagnosticKind,
69}
70
71impl fmt::Display for EnvironmentDiagnostic {
72    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match &self.kind {
74            EnvironmentDiagnosticKind::UnknownName { suggestion } => {
75                write!(
76                    formatter,
77                    "{}: unknown Harn environment variable `{}`.",
78                    self.code, self.key
79                )?;
80                if let Some(suggestion) = suggestion {
81                    write!(formatter, " Did you mean `{suggestion}`?")?;
82                }
83                formatter.write_str(" Use `HARN_EXT_<NAME>` for settings owned by a calling tool.")
84            }
85            EnvironmentDiagnosticKind::InvalidValue { expected } => write!(
86                formatter,
87                "{}: environment variable `{}` must be {}",
88                self.code,
89                self.key,
90                expected.description()
91            ),
92        }
93    }
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub struct EnvironmentValidationError {
98    diagnostics: Vec<EnvironmentDiagnostic>,
99}
100
101impl EnvironmentValidationError {
102    pub fn diagnostics(&self) -> &[EnvironmentDiagnostic] {
103        &self.diagnostics
104    }
105}
106
107impl fmt::Display for EnvironmentValidationError {
108    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109        for (index, diagnostic) in self.diagnostics.iter().enumerate() {
110            if index > 0 {
111                formatter.write_str("\n")?;
112            }
113            diagnostic.fmt(formatter)?;
114        }
115        Ok(())
116    }
117}
118
119impl std::error::Error for EnvironmentValidationError {}
120
121impl EnvironmentValueShape {
122    fn description(self) -> String {
123        match self {
124            Self::OwnerValidated => "valid for its owning subsystem".to_string(),
125            Self::Boolean => {
126                "a boolean (`true`, `false`, `yes`, `no`, `on`, `off`, `1`, or `0`)".to_string()
127            }
128            Self::UnsignedInteger => "an unsigned integer".to_string(),
129            Self::NonNegativeNumber => "a non-negative number".to_string(),
130            Self::UnitInterval => "a number between 0 and 1".to_string(),
131            Self::Enumerated(values) => format!(
132                "one of {}",
133                values
134                    .iter()
135                    .map(|value| format!("`{value}`"))
136                    .collect::<Vec<_>>()
137                    .join(", ")
138            ),
139        }
140    }
141
142    fn accepts(self, value: &OsStr) -> bool {
143        let Some(value) = value.to_str() else {
144            return matches!(self, Self::OwnerValidated);
145        };
146        match self {
147            Self::OwnerValidated => true,
148            Self::Boolean => matches!(
149                value.trim().to_ascii_lowercase().as_str(),
150                "1" | "0" | "true" | "false" | "yes" | "no" | "on" | "off"
151            ),
152            Self::UnsignedInteger => value.trim().parse::<u64>().is_ok(),
153            Self::NonNegativeNumber => value
154                .trim()
155                .parse::<f64>()
156                .is_ok_and(|parsed| parsed.is_finite() && parsed >= 0.0),
157            Self::UnitInterval => value
158                .trim()
159                .parse::<f64>()
160                .is_ok_and(|parsed| parsed.is_finite() && (0.0..=1.0).contains(&parsed)),
161            Self::Enumerated(values) => {
162                let value = value.trim().to_ascii_lowercase();
163                values.contains(&value.as_str())
164            }
165        }
166    }
167}
168
169/// Look up registry metadata for a Harn-owned key.
170pub fn variable_spec(name: &str) -> Option<EnvironmentVariableSpec> {
171    if registered_names().binary_search(&name).is_ok() {
172        return Some(spec_for_registered_name(name));
173    }
174    if is_extension_name(name) {
175        return Some(EnvironmentVariableSpec {
176            name: name.to_string(),
177            consumer: EnvironmentConsumer::EmbedderExtension,
178            value_shape: EnvironmentValueShape::OwnerValidated,
179            sensitivity: sensitivity_for(name),
180        });
181    }
182    if is_structured_runtime_name(name) {
183        return Some(spec_for_registered_name(name));
184    }
185    None
186}
187
188/// Validate the live process environment at the CLI/embedded-runtime startup
189/// boundary.
190pub fn validate_startup_environment() -> Result<(), EnvironmentValidationError> {
191    validate_environment(std::env::vars_os())
192}
193
194/// Validate a supplied environment snapshot. This is the deterministic core
195/// used by hosts and tests; values never enter diagnostics.
196pub fn validate_environment<I, K, V>(vars: I) -> Result<(), EnvironmentValidationError>
197where
198    I: IntoIterator<Item = (K, V)>,
199    K: Into<OsString>,
200    V: Into<OsString>,
201{
202    let mut diagnostics = Vec::new();
203    for (key, value) in vars {
204        let key = key.into();
205        let Some(key) = key.to_str() else {
206            continue;
207        };
208        if !key.starts_with("HARN_") {
209            continue;
210        }
211        let Some(spec) = variable_spec(key) else {
212            diagnostics.push(EnvironmentDiagnostic {
213                code: UNKNOWN_CODE,
214                key: key.to_string(),
215                kind: EnvironmentDiagnosticKind::UnknownName {
216                    suggestion: nearest_registered_name(key),
217                },
218            });
219            continue;
220        };
221        let value = value.into();
222        if !spec.value_shape.accepts(&value) {
223            diagnostics.push(EnvironmentDiagnostic {
224                code: INVALID_VALUE_CODE,
225                key: key.to_string(),
226                kind: EnvironmentDiagnosticKind::InvalidValue {
227                    expected: spec.value_shape,
228                },
229            });
230        }
231    }
232    diagnostics.sort_by(|left, right| left.key.cmp(&right.key));
233    if diagnostics.is_empty() {
234        Ok(())
235    } else {
236        Err(EnvironmentValidationError { diagnostics })
237    }
238}
239
240fn registered_names() -> &'static [&'static str] {
241    static NAMES: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
242    NAMES
243        .get_or_init(|| {
244            REGISTERED_NAMES
245                .lines()
246                .filter(|name| !name.is_empty())
247                .collect()
248        })
249        .as_slice()
250}
251
252fn spec_for_registered_name(name: &str) -> EnvironmentVariableSpec {
253    EnvironmentVariableSpec {
254        name: name.to_string(),
255        consumer: consumer_for(name),
256        value_shape: value_shape_for(name),
257        sensitivity: sensitivity_for(name),
258    }
259}
260
261fn consumer_for(name: &str) -> EnvironmentConsumer {
262    if name.starts_with("HARN_TEST_") || name.contains("_TEST_") || name.starts_with("HARN_E2E_") {
263        EnvironmentConsumer::TestHarness
264    } else if [
265        "HARN_BUILD_",
266        "HARN_CARGO_",
267        "HARN_CHECK_",
268        "HARN_CI_",
269        "HARN_CODEGEN_",
270        "HARN_DEV_",
271        "HARN_RELEASE_",
272    ]
273    .iter()
274    .any(|prefix| name.starts_with(prefix))
275    {
276        EnvironmentConsumer::BuildTooling
277    } else if name.starts_with("HARN_CLI_")
278        || name.starts_with("HARN_DOCTOR_")
279        || name.starts_with("HARN_INIT_")
280    {
281        EnvironmentConsumer::Cli
282    } else {
283        EnvironmentConsumer::Runtime
284    }
285}
286
287fn value_shape_for(name: &str) -> EnvironmentValueShape {
288    match name {
289        "HARN_LLM_TIMEOUT"
290        | "HARN_LLM_IDLE_TIMEOUT"
291        | "HARN_LLM_FIRST_TOKEN_TIMEOUT"
292        | "HARN_MAX_CONCURRENCY"
293        | "HARN_RETENTION_DAYS"
294        | "HARN_TOKEN_BUDGET"
295        | "HARN_EVENT_LOG_QUEUE_DEPTH" => EnvironmentValueShape::UnsignedInteger,
296        "HARN_BUDGET_USD" => EnvironmentValueShape::NonNegativeNumber,
297        "HARN_OTEL_SAMPLE_RATIO" => EnvironmentValueShape::UnitInterval,
298        "HARN_BYTECODE_CACHE"
299        | "HARN_FLIGHT_RECORDER"
300        | harn_parser::HARN_LEGACY_AMBIENT_CAPABILITIES_ENV
301        | "HARN_LLM_STREAM"
302        | "HARN_REPLAY_ENABLED"
303        | "HARN_REQUIRE_SIGNED_SKILLS"
304        | "HARN_TRACE"
305        | "HARN_VERBOSE_CONFIG" => EnvironmentValueShape::Boolean,
306        crate::vm::subtask::PLACEMENT_ENV => {
307            EnvironmentValueShape::Enumerated(crate::vm::subtask::PLACEMENT_VALUES)
308        }
309        _ => EnvironmentValueShape::OwnerValidated,
310    }
311}
312
313fn sensitivity_for(name: &str) -> EnvironmentSensitivity {
314    if [
315        "TOKEN",
316        "SECRET",
317        "PASSWORD",
318        "API_KEY",
319        "OAUTH_KEY",
320        "HEADERS",
321        "PRIVATE_KEY",
322    ]
323    .iter()
324    .any(|fragment| name.contains(fragment))
325    {
326        EnvironmentSensitivity::Credential
327    } else {
328        EnvironmentSensitivity::Public
329    }
330}
331
332/// Downstream embedders own this one explicit namespace. A nonempty
333/// uppercase-identifier suffix prevents `HARN_EXT_` from becoming a blanket
334/// bypass for malformed names.
335fn is_extension_name(name: &str) -> bool {
336    name.strip_prefix("HARN_EXT_")
337        .is_some_and(is_upper_identifier)
338}
339
340/// Runtime-generated families have a structural grammar instead of a broad
341/// prefix exception. This admits model-role, rate-limit, and secret-provider
342/// keys without accepting near-miss fixed keys such as `HARN_LLM_TIMOUT`.
343fn is_structured_runtime_name(name: &str) -> bool {
344    is_secret_name(name)
345        || is_rate_limit_name(name)
346        || is_model_role_name(name)
347        || is_agent_model_option_name(name)
348}
349
350fn is_secret_name(name: &str) -> bool {
351    name.strip_prefix("HARN_SECRET_")
352        .is_some_and(is_upper_identifier)
353}
354
355fn is_rate_limit_name(name: &str) -> bool {
356    let Some(suffix) = name.strip_prefix("HARN_RATE_LIMIT_") else {
357        return false;
358    };
359    let Some((provider, field)) = suffix.rsplit_once('_') else {
360        return false;
361    };
362    is_upper_identifier(provider) && matches!(field, "QUEUE" | "RPM" | "TPM" | "CONCURRENCY")
363}
364
365fn is_model_role_name(name: &str) -> bool {
366    let suffix = name
367        .strip_prefix("HARN_LLM_ROLE_")
368        .or_else(|| name.strip_prefix("HARN_LLM_"));
369    let Some(suffix) = suffix else {
370        return false;
371    };
372    ["_MODEL", "_PROVIDER", "_ROUTE_POLICY"]
373        .iter()
374        .find_map(|ending| suffix.strip_suffix(ending))
375        .is_some_and(is_upper_identifier)
376}
377
378/// `std/agent/options` derives role-specific configuration keys from a role
379/// token and a closed suffix vocabulary. Keep that dynamic reader family
380/// structural so custom roles do not require per-role registry entries.
381fn is_agent_model_option_name(name: &str) -> bool {
382    const SUFFIXES: &[&str] = &[
383        "_EFFORT",
384        "_MODEL",
385        "_MODEL_ROLE",
386        "_PROVIDER",
387        "_REASONING_TASK",
388        "_TOOL_FORMAT",
389    ];
390    let Some(prefix) = SUFFIXES.iter().find_map(|suffix| name.strip_suffix(suffix)) else {
391        return false;
392    };
393    let Some(prefix) = prefix.strip_prefix("HARN_") else {
394        return false;
395    };
396    let role = prefix
397        .strip_prefix("AGENT_")
398        .or_else(|| prefix.strip_prefix("LLM_"))
399        .unwrap_or(prefix);
400    matches!(prefix, "AGENT" | "LLM") || is_upper_identifier(role)
401}
402
403fn is_upper_identifier(value: &str) -> bool {
404    !value.is_empty()
405        && value
406            .bytes()
407            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
408        && !value.starts_with('_')
409        && !value.ends_with('_')
410}
411
412fn nearest_registered_name(name: &str) -> Option<String> {
413    registered_names()
414        .iter()
415        .copied()
416        .filter(|candidate| !candidate.ends_with('_'))
417        .map(|candidate| (strsim::levenshtein(name, candidate), candidate))
418        .min_by_key(|(distance, candidate)| (*distance, *candidate))
419        .filter(|(distance, _)| *distance <= 3)
420        .map(|(_, candidate)| candidate.to_string())
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn typo_is_typed_and_suggests_registered_name() {
429        let error = validate_environment([("HARN_LLM_TIMOUT", "30")]).unwrap_err();
430        assert_eq!(
431            error.diagnostics(),
432            &[EnvironmentDiagnostic {
433                code: UNKNOWN_CODE,
434                key: "HARN_LLM_TIMOUT".to_string(),
435                kind: EnvironmentDiagnosticKind::UnknownName {
436                    suggestion: Some("HARN_LLM_TIMEOUT".to_string()),
437                },
438            }]
439        );
440    }
441
442    #[test]
443    fn known_and_structured_extension_names_are_accepted() {
444        validate_environment([
445            ("HARN_LLM_TIMEOUT", "30"),
446            ("HARN_EXT_ACME_MODE", "custom"),
447            ("HARN_LLM_ROLE_REVIEW_MODEL", "reviewer"),
448            ("HARN_SECRET_ACME_TOKEN", "credential"),
449        ])
450        .unwrap();
451    }
452
453    #[test]
454    fn malformed_extension_name_is_not_a_prefix_bypass() {
455        let error = validate_environment([("HARN_EXT_", "anything")]).unwrap_err();
456        assert!(matches!(
457            error.diagnostics()[0].kind,
458            EnvironmentDiagnosticKind::UnknownName { .. }
459        ));
460    }
461
462    /// These names are composed at read time rather than written literally, so
463    /// no source scan can find them and the reverse drift gate cannot see them.
464    /// `std/agent/options` builds each key as one of the `HARN_AGENT`,
465    /// `HARN_LLM`, `HARN_AGENT_<ROLE>`, `HARN_LLM_<ROLE>`, or `HARN_<ROLE>`
466    /// prefixes joined to a closed suffix vocabulary, which is why the grammar
467    /// admits a name such as `HARN_LLM_TOOL_FORMAT` that grep alone would read
468    /// as a near miss for `HARN_AGENT_TOOL_FORMAT`.
469    #[test]
470    fn dynamic_agent_role_options_follow_a_closed_structural_grammar() {
471        for name in [
472            "HARN_AGENT_MODEL",
473            "HARN_LLM_TOOL_FORMAT",
474            "HARN_AGENT_REVIEW_PROVIDER",
475            "HARN_LLM_PLANNER_REASONING_TASK",
476            "HARN_RELEASE_EFFORT",
477        ] {
478            assert!(variable_spec(name).is_some(), "{name}");
479        }
480        for name in [
481            "HARN_AGENT_REVIEW_UNKNOWN",
482            "HARN_LLM_TIMOUT",
483            "HARN_RELEASE_",
484        ] {
485            assert!(variable_spec(name).is_none(), "{name}");
486        }
487    }
488
489    #[test]
490    fn credential_metadata_covers_non_api_oauth_keys() {
491        assert_eq!(
492            variable_spec("HARN_OAUTH_KEY").unwrap().sensitivity,
493            EnvironmentSensitivity::Credential
494        );
495    }
496
497    #[test]
498    fn invalid_known_value_is_rejected_at_startup() {
499        let error = validate_environment([("HARN_LLM_TIMEOUT", "soon")]).unwrap_err();
500        assert_eq!(
501            error.diagnostics()[0].kind,
502            EnvironmentDiagnosticKind::InvalidValue {
503                expected: EnvironmentValueShape::UnsignedInteger
504            }
505        );
506    }
507
508    #[test]
509    fn subtask_placement_uses_the_runtime_owned_closed_vocabulary() {
510        let spec = variable_spec(crate::vm::subtask::PLACEMENT_ENV).unwrap();
511        assert_eq!(
512            spec.value_shape,
513            EnvironmentValueShape::Enumerated(crate::vm::subtask::PLACEMENT_VALUES)
514        );
515        validate_environment([(crate::vm::subtask::PLACEMENT_ENV, "worker")]).unwrap();
516        validate_environment([(crate::vm::subtask::PLACEMENT_ENV, "CURRENT_THREAD")]).unwrap();
517
518        let error =
519            validate_environment([(crate::vm::subtask::PLACEMENT_ENV, "workers")]).unwrap_err();
520        assert_eq!(
521            error.diagnostics()[0].kind,
522            EnvironmentDiagnosticKind::InvalidValue {
523                expected: EnvironmentValueShape::Enumerated(crate::vm::subtask::PLACEMENT_VALUES)
524            }
525        );
526        assert!(error.to_string().contains("`worker`, `current_thread`"));
527    }
528
529    #[test]
530    fn diagnostics_cannot_render_values_even_for_credentials() {
531        let secret = "must-never-appear";
532        let error = validate_environment([("HARN_CLOUD_API_KEZ", secret)]).unwrap_err();
533        let rendered = error.to_string();
534        assert!(rendered.contains("HARN_CLOUD_API_KEZ"));
535        assert!(!rendered.contains(secret));
536    }
537
538    #[test]
539    fn registry_is_sorted_unique_and_contains_metadata() {
540        let names = registered_names();
541        assert!(
542            names.windows(2).all(|pair| pair[0] < pair[1]),
543            "environment registry must remain sorted and unique"
544        );
545        let timeout = variable_spec("HARN_LLM_TIMEOUT").unwrap();
546        assert_eq!(timeout.consumer, EnvironmentConsumer::Runtime);
547        assert_eq!(timeout.value_shape, EnvironmentValueShape::UnsignedInteger);
548        let token = variable_spec("HARN_PACKAGE_REGISTRY_TOKEN").unwrap();
549        assert_eq!(token.sensitivity, EnvironmentSensitivity::Credential);
550        let host_providers = variable_spec(crate::llm_config::HOST_PROVIDERS_CONFIG_ENV).unwrap();
551        assert_eq!(host_providers.consumer, EnvironmentConsumer::Runtime);
552        assert_eq!(
553            host_providers.value_shape,
554            EnvironmentValueShape::OwnerValidated
555        );
556    }
557
558    #[test]
559    fn embedded_runtime_bootstrap_accepts_registered_process_environment() {
560        crate::initialize_runtime().expect("registered process environment");
561    }
562
563    #[test]
564    fn every_compiled_harn_name_is_registered_or_structurally_owned() {
565        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
566            .ancestors()
567            .nth(2)
568            .expect("harn-vm lives below workspace/crates");
569        let protocol_symbols = protocol_artifact_symbol_names(workspace_root);
570        let crates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
571            .parent()
572            .expect("harn-vm lives below crates");
573        let mut missing = std::collections::BTreeSet::new();
574        for entry in walkdir::WalkDir::new(crates_dir)
575            .into_iter()
576            .filter_map(Result::ok)
577            .filter(|entry| {
578                entry.file_type().is_file()
579                    && entry.path().extension().and_then(OsStr::to_str) == Some("rs")
580                    && entry
581                        .path()
582                        .components()
583                        .any(|component| component.as_os_str() == "src")
584                    && !is_protocol_artifact_projection(entry.path())
585                    && entry.file_name() != "environment_registry.rs"
586            })
587        {
588            let source = std::fs::read_to_string(entry.path()).expect("read Rust source");
589            for token in harn_name_tokens(&source) {
590                if variable_spec(token).is_none()
591                    && !protocol_symbols.contains(token)
592                    && !matches!(token, "HARN_LLM_" | "HARN_LLM_ROLE_" | "HARN_SECRET_")
593                {
594                    missing.insert(format!("{}: {token}", entry.path().display()));
595                }
596            }
597        }
598        assert!(
599            missing.is_empty(),
600            "compiled HARN_* names missing from environment_registry_names.txt:\n{}",
601            missing.into_iter().collect::<Vec<_>>().join("\n")
602        );
603    }
604
605    #[test]
606    fn every_harn_script_owned_name_is_registered_or_structurally_owned() {
607        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
608            .ancestors()
609            .nth(2)
610            .expect("harn-vm lives below workspace/crates");
611        let source_roots = [
612            "benchmarks",
613            "conformance",
614            "crates",
615            "evals",
616            "examples",
617            "experiments",
618            "perf",
619            "personas",
620            "scripts",
621            "tests",
622        ];
623        let protocol_symbols = protocol_artifact_symbol_names(workspace_root);
624        let mut missing = std::collections::BTreeSet::new();
625        for source_root in source_roots {
626            let source_root = workspace_root.join(source_root);
627            if !source_root.exists() {
628                continue;
629            }
630            for entry in walkdir::WalkDir::new(source_root)
631                .into_iter()
632                .filter_entry(|entry| !is_pruned_reference_directory(entry))
633                .filter_map(Result::ok)
634                .filter(|entry| {
635                    entry.file_type().is_file()
636                        && entry.path().extension().and_then(OsStr::to_str) == Some("harn")
637                })
638            {
639                let source = std::fs::read_to_string(entry.path()).expect("read Harn source");
640                for token in harn_name_tokens(&source) {
641                    if variable_spec(token).is_none()
642                        && !protocol_symbols.contains(token)
643                        && !matches!(
644                            token,
645                            "HARN_AGENT"
646                                | "HARN_AGENT_"
647                                | "HARN_LLM"
648                                | "HARN_LLM_"
649                                | "HARN_PLANNER"
650                                | "HARN_RELEASE"
651                        )
652                    {
653                        missing.insert(format!("{}: {token}", entry.path().display()));
654                    }
655                }
656            }
657        }
658        assert!(
659            missing.is_empty(),
660            "Harn-script HARN_* names missing from environment_registry_names.txt:\n{}",
661            missing.into_iter().collect::<Vec<_>>().join("\n")
662        );
663    }
664
665    /// The `HARN_*` names a workflow file ASSIGNS.
666    ///
667    /// Only the assigning forms count. A name that is merely referenced cannot
668    /// put itself into a child process's environment, so it cannot trip
669    /// `validate_startup_environment`; a name a workflow sets can, and will.
670    /// `harn_name_tokens` cannot serve here because it anchors on `"HARN_`,
671    /// and a YAML mapping key is bare.
672    fn workflow_assigned_names(source: &str) -> std::collections::BTreeSet<String> {
673        let mut names = std::collections::BTreeSet::new();
674        for line in source.lines() {
675            let trimmed = line.trim_start();
676            let candidate = trimmed.strip_prefix("export ").unwrap_or(trimmed);
677            let Some(rest) = candidate.strip_prefix("HARN_") else {
678                continue;
679            };
680            let mut name = String::from("HARN_");
681            let mut terminator = None;
682            for character in rest.chars() {
683                if character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'
684                {
685                    name.push(character);
686                } else {
687                    terminator = Some(character);
688                    break;
689                }
690            }
691            if name == "HARN_" {
692                continue;
693            }
694            // `NAME:` is a YAML mapping key and `NAME=` is a shell assignment.
695            // Anything else is prose, a reference on a right-hand side, or a
696            // bare token that assigns nothing.
697            if !matches!(terminator, Some(':') | Some('=')) {
698                continue;
699            }
700            names.insert(name);
701        }
702        names
703    }
704
705    #[test]
706    fn workflow_name_scan_reads_assignments_and_ignores_references() {
707        let source = concat!(
708            "        env:\n",
709            "          HARN_BUMP_BRANCH: main\n",
710            "        run: |\n",
711            "          export HARN_BUMP_REFRESH=\"$HARN_BUMP_REFRESH_COMMAND\"\n",
712            "          echo \"$HARN_BUMP_TOKEN\"\n",
713            "          # HARN_BUMP_NOT_A_KEY: prose\n",
714        );
715        let names = workflow_assigned_names(source);
716        assert!(names.contains("HARN_BUMP_BRANCH"), "reads a YAML env key");
717        assert!(names.contains("HARN_BUMP_REFRESH"), "reads a shell export");
718        assert!(
719            !names.contains("HARN_BUMP_REFRESH_COMMAND"),
720            "a reference on a right-hand side is not an assignment"
721        );
722        assert!(
723            !names.contains("HARN_BUMP_TOKEN"),
724            "an echoed reference is not an assignment"
725        );
726        assert!(
727            !names.contains("HARN_BUMP_NOT_A_KEY"),
728            "a comment is not an assignment"
729        );
730    }
731
732    /// A workflow that sets an unregistered `HARN_*` name ships a job that dies
733    /// on `HARN-ENV-001` the first time it starts a Harn process. The compiled
734    /// and Harn-script censuses above never see it, because a workflow is
735    /// neither. This closes that hole.
736    #[test]
737    fn every_workflow_assigned_name_is_registered() {
738        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
739            .ancestors()
740            .nth(2)
741            .expect("harn-vm lives below workspace/crates");
742        let workflows = workspace_root.join(".github/workflows");
743        assert!(
744            workflows.is_dir(),
745            "no workflow directory at {}; this gate would pass by seeing nothing",
746            workflows.display()
747        );
748        let mut scanned = 0usize;
749        let mut missing = std::collections::BTreeSet::new();
750        for entry in walkdir::WalkDir::new(&workflows)
751            .into_iter()
752            .filter_map(Result::ok)
753            .filter(|entry| {
754                entry.file_type().is_file()
755                    && matches!(
756                        entry.path().extension().and_then(OsStr::to_str),
757                        Some("yml") | Some("yaml")
758                    )
759            })
760        {
761            let source = std::fs::read_to_string(entry.path()).expect("read workflow");
762            scanned += 1;
763            for name in workflow_assigned_names(&source) {
764                if variable_spec(&name).is_none() {
765                    missing.insert(format!("{}: {name}", entry.path().display()));
766                }
767            }
768        }
769        assert!(
770            scanned > 0,
771            "scanned no workflow files; the gate cannot see anything"
772        );
773        assert!(
774            missing.is_empty(),
775            "workflow-assigned HARN_* names missing from \
776             environment_registry_names.txt:\n{}",
777            missing.into_iter().collect::<Vec<_>>().join("\n")
778        );
779    }
780
781    /// Registered names that have no repository-owned reference, and why they must
782    /// stay. Every entry is a standing exception to the reverse drift gate, so
783    /// it carries the reason a source reference cannot exist in this tree. An
784    /// entry that gains an owner is rejected too, which keeps the list from
785    /// rotting.
786    const UNREAD_NAME_ALLOWLIST: &[(&str, &str)] = &[];
787
788    /// Build output, caches, vendored dependencies, and nested checkouts are
789    /// not sources of truth for variable ownership. A nested worktree is the
790    /// dangerous one: it carries its own copy of the registry and literals, so
791    /// walking into it would make any name look alive.
792    fn is_pruned_reference_directory(entry: &walkdir::DirEntry) -> bool {
793        if !entry.file_type().is_dir() {
794            return false;
795        }
796        let Some(name) = entry.file_name().to_str() else {
797            return true;
798        };
799        name.starts_with(".target")
800            || matches!(
801                name,
802                ".build"
803                    | ".burin"
804                    | ".git"
805                    | ".harn"
806                    | ".harn-runs"
807                    | ".harn-toolchain-cache"
808                    | ".venv"
809                    | ".worktrees"
810                    | "__pycache__"
811                    | "changelog"
812                    | "dist"
813                    | "node_modules"
814                    | "pkg"
815                    | "target"
816            )
817    }
818
819    #[test]
820    fn generated_harn_state_is_not_an_environment_contract_owner() {
821        let root = tempfile::tempdir().expect("temporary workspace");
822        let state = root.path().join(".harn");
823        std::fs::create_dir(&state).expect("create generated Harn state");
824        let entry = walkdir::WalkDir::new(root.path())
825            .into_iter()
826            .filter_map(Result::ok)
827            .find(|entry| entry.path() == state)
828            .expect("walk generated Harn state");
829
830        assert!(is_pruned_reference_directory(&entry));
831    }
832
833    /// Generated protocol names describe wire contracts; they are not process
834    /// environment reads. Exclude both their generator and generated output so
835    /// a public constant cannot accidentally keep an environment variable alive.
836    fn is_protocol_artifact_projection(path: &std::path::Path) -> bool {
837        path.components().any(|component| {
838            matches!(
839                component.as_os_str().to_str(),
840                Some("dump_protocol_artifacts" | "protocol-artifacts")
841            )
842        })
843    }
844
845    /// Prose can mention a retired name forever, so release notes and docs do
846    /// not count as owners. Registries and protocol projections are excluded
847    /// for the same reason: listing a name is not an environment contract.
848    fn is_reference_source(path: &std::path::Path) -> bool {
849        if path.extension().and_then(OsStr::to_str) == Some("md")
850            || is_protocol_artifact_projection(path)
851        {
852            return false;
853        }
854        !matches!(
855            path.file_name().and_then(OsStr::to_str),
856            Some("environment_registry.rs" | "environment_registry_names.txt")
857        )
858    }
859
860    /// The generated Rust projection is the authority for public protocol
861    /// symbol names. Deriving this set keeps the environment registry from
862    /// maintaining a second list of `HARN_*` constants.
863    fn protocol_artifact_symbol_names(
864        workspace_root: &std::path::Path,
865    ) -> std::collections::BTreeSet<String> {
866        let path = workspace_root.join("spec/protocol-artifacts/harn-protocol.rs");
867        let source = std::fs::read_to_string(&path)
868            .unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
869        bounded_harn_tokens(&source).map(str::to_string).collect()
870    }
871
872    #[test]
873    fn protocol_artifact_names_are_not_environment_references() {
874        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
875            .ancestors()
876            .nth(2)
877            .expect("harn-vm lives below workspace/crates");
878        let protocol_symbols = protocol_artifact_symbol_names(workspace_root);
879        let generator = std::path::Path::new(
880            "crates/harn-cli/src/commands/dump_protocol_artifacts/typescript.rs",
881        );
882        let projection = std::path::Path::new("spec/protocol-artifacts/harn-protocol.ts");
883        let runtime = std::path::Path::new("crates/harn-vm/src/llm/call.rs");
884
885        assert!(is_protocol_artifact_projection(generator));
886        assert!(is_protocol_artifact_projection(projection));
887        assert!(!is_reference_source(generator));
888        assert!(!is_reference_source(projection));
889        assert!(is_reference_source(runtime));
890        assert!(variable_spec("HARN_AGENT_EVENT_KINDS").is_none());
891        assert!(variable_spec("HARN_WORKER_STATUSES").is_none());
892        assert!(protocol_symbols.contains("HARN_PROTOCOL_ARTIFACT_VERSION"));
893        assert!(protocol_symbols.contains("HARN_AGENT_EVENT_KINDS"));
894        assert!(!protocol_symbols.contains("HARN_ACP_VERBOSE"));
895    }
896
897    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
898    enum EnvironmentReferenceSyntax {
899        StringLiteral,
900        ShellLike,
901    }
902
903    fn environment_reference_syntax(path: &std::path::Path) -> EnvironmentReferenceSyntax {
904        let file_name = path.file_name().and_then(OsStr::to_str).unwrap_or_default();
905        let extension = path.extension().and_then(OsStr::to_str).unwrap_or_default();
906        if matches!(file_name, "Makefile" | "GNUmakefile")
907            || file_name.starts_with(".env")
908            || matches!(
909                extension,
910                "bash" | "bat" | "cmd" | "fish" | "mk" | "ps1" | "sh" | "yaml" | "yml" | "zsh"
911            )
912        {
913            EnvironmentReferenceSyntax::ShellLike
914        } else {
915            EnvironmentReferenceSyntax::StringLiteral
916        }
917    }
918
919    fn environment_reference_tokens<'a>(path: &std::path::Path, source: &'a str) -> Vec<&'a str> {
920        match environment_reference_syntax(path) {
921            EnvironmentReferenceSyntax::StringLiteral => harn_name_tokens(source).collect(),
922            EnvironmentReferenceSyntax::ShellLike => bounded_harn_tokens(source).collect(),
923        }
924    }
925
926    /// Every maximal `HARN_*` token that starts at an identifier boundary.
927    /// Shell, Make, and YAML reference variables without quoting their names;
928    /// compiled languages use the literal-only scanner instead.
929    #[expect(
930        clippy::string_slice,
931        reason = "start/end bound an ASCII HARN_* token found by match_indices"
932    )]
933    fn bounded_harn_tokens(source: &str) -> impl Iterator<Item = &str> {
934        let bytes = source.as_bytes();
935        source.match_indices("HARN_").filter_map(move |(start, _)| {
936            if start > 0 {
937                let previous = bytes[start - 1];
938                if previous.is_ascii_alphanumeric() || previous == b'_' {
939                    return None;
940                }
941            }
942            let mut end = start + "HARN_".len();
943            while end < bytes.len()
944                && (bytes[end].is_ascii_uppercase()
945                    || bytes[end].is_ascii_digit()
946                    || bytes[end] == b'_')
947            {
948                end += 1;
949            }
950            Some(&source[start..end])
951        })
952    }
953
954    /// The forward gates prove that every environment-shaped source reference
955    /// is registered. The reverse direction rejects rows whose owner vanished,
956    /// while generated protocol identifiers cannot keep unrelated environment
957    /// knobs alive in compiled languages.
958    #[test]
959    fn every_registered_name_has_a_non_projection_source_reference() {
960        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
961            .ancestors()
962            .nth(2)
963            .expect("harn-vm lives below workspace/crates");
964        let mut referenced = std::collections::BTreeSet::new();
965        let protocol_symbols = protocol_artifact_symbol_names(workspace_root);
966        for entry in walkdir::WalkDir::new(workspace_root)
967            .into_iter()
968            .filter_entry(|entry| !is_pruned_reference_directory(entry))
969            .filter_map(Result::ok)
970            .filter(|entry| entry.file_type().is_file() && is_reference_source(entry.path()))
971        {
972            let Ok(source) = std::fs::read_to_string(entry.path()) else {
973                continue;
974            };
975            for token in environment_reference_tokens(entry.path(), &source) {
976                if !protocol_symbols.contains(token) {
977                    referenced.insert(token.to_string());
978                }
979            }
980        }
981
982        let allowed: std::collections::BTreeSet<&str> = UNREAD_NAME_ALLOWLIST
983            .iter()
984            .map(|(name, _)| *name)
985            .collect();
986        let unregistered_allowlist = allowed
987            .iter()
988            .copied()
989            .filter(|name| registered_names().binary_search(name).is_err())
990            .collect::<Vec<_>>();
991        assert!(
992            unregistered_allowlist.is_empty(),
993            "allowlisted names are not in environment_registry_names.txt:\n{}",
994            unregistered_allowlist.join("\n")
995        );
996        let owned_allowlist = allowed
997            .iter()
998            .copied()
999            .filter(|name| referenced.contains(*name))
1000            .collect::<Vec<_>>();
1001        assert!(
1002            owned_allowlist.is_empty(),
1003            "allowlisted names now have source owners; drop them from UNREAD_NAME_ALLOWLIST:\n{}",
1004            owned_allowlist.join("\n")
1005        );
1006
1007        let unread = registered_names()
1008            .iter()
1009            .copied()
1010            .filter(|name| !referenced.contains(*name) && !allowed.contains(name))
1011            .collect::<Vec<_>>();
1012        assert!(
1013            unread.is_empty(),
1014            "registered names have no source owner; delete them from \
1015             environment_registry_names.txt or allowlist them with a reason:\n{}",
1016            unread.join("\n")
1017        );
1018    }
1019
1020    #[test]
1021    fn environment_reference_scan_dispatches_by_source_syntax() {
1022        let compiled = concat!(
1023            "const HARN_AGENT_EVENT_KINDS: &[&str] = &[];\n",
1024            "const ENV: &str = \"HARN_REAL_ENVIRONMENT_KNOB\";\n",
1025        );
1026        assert_eq!(
1027            environment_reference_tokens(std::path::Path::new("runtime.rs"), compiled),
1028            vec!["HARN_REAL_ENVIRONMENT_KNOB"]
1029        );
1030        assert_eq!(
1031            environment_reference_tokens(
1032                std::path::Path::new("bench.sh"),
1033                "cache=${HARN_BENCH_CACHE_DIR:-target}\n",
1034            ),
1035            vec!["HARN_BENCH_CACHE_DIR"]
1036        );
1037        assert_eq!(
1038            environment_reference_tokens(
1039                std::path::Path::new("Makefile"),
1040                "HARN_BIN_ASSIGN = harn_bin\n",
1041            ),
1042            vec!["HARN_BIN_ASSIGN"]
1043        );
1044    }
1045
1046    #[expect(
1047        clippy::string_slice,
1048        reason = "start/end bound an ASCII HARN_* token found by match_indices"
1049    )]
1050    fn harn_name_tokens(source: &str) -> impl Iterator<Item = &str> {
1051        source.match_indices("\"HARN_").filter_map(|(quote, _)| {
1052            let start = quote + 1;
1053            let bytes = source.as_bytes();
1054            let mut end = start + "HARN_".len();
1055            while end < bytes.len()
1056                && (bytes[end].is_ascii_uppercase()
1057                    || bytes[end].is_ascii_digit()
1058                    || bytes[end] == b'_')
1059            {
1060                end += 1;
1061            }
1062            (end > start + "HARN_".len()).then(|| &source[start..end])
1063        })
1064    }
1065}