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_RETENTION_DAYS"
293        | "HARN_EVENT_LOG_QUEUE_DEPTH" => EnvironmentValueShape::UnsignedInteger,
294        "HARN_OTEL_SAMPLE_RATIO" => EnvironmentValueShape::UnitInterval,
295        "HARN_ALLOW_TOOLCHAIN_MISMATCH"
296        | "HARN_BYTECODE_CACHE"
297        | "HARN_DISPATCH_GENERATION_DEBUG"
298        | "HARN_FLIGHT_RECORDER"
299        | harn_parser::HARN_LEGACY_AMBIENT_CAPABILITIES_ENV
300        | "HARN_LLM_STREAM"
301        | "HARN_REPLAY_ENABLED"
302        | "HARN_REQUIRE_SIGNED_SKILLS"
303        | "HARN_TRACE"
304        | "HARN_VERBOSE_CONFIG" => EnvironmentValueShape::Boolean,
305        crate::vm::subtask::PLACEMENT_ENV => {
306            EnvironmentValueShape::Enumerated(crate::vm::subtask::PLACEMENT_VALUES)
307        }
308        _ => EnvironmentValueShape::OwnerValidated,
309    }
310}
311
312fn sensitivity_for(name: &str) -> EnvironmentSensitivity {
313    if [
314        "TOKEN",
315        "SECRET",
316        "PASSWORD",
317        "API_KEY",
318        "OAUTH_KEY",
319        "HEADERS",
320        "PRIVATE_KEY",
321    ]
322    .iter()
323    .any(|fragment| name.contains(fragment))
324    {
325        EnvironmentSensitivity::Credential
326    } else {
327        EnvironmentSensitivity::Public
328    }
329}
330
331/// Downstream embedders own this one explicit namespace. A nonempty
332/// uppercase-identifier suffix prevents `HARN_EXT_` from becoming a blanket
333/// bypass for malformed names.
334fn is_extension_name(name: &str) -> bool {
335    name.strip_prefix("HARN_EXT_")
336        .is_some_and(is_upper_identifier)
337}
338
339/// Runtime-generated families have a structural grammar instead of a broad
340/// prefix exception. This admits model-role, rate-limit, and secret-provider
341/// keys without accepting near-miss fixed keys such as `HARN_LLM_TIMOUT`.
342fn is_structured_runtime_name(name: &str) -> bool {
343    is_secret_name(name)
344        || is_rate_limit_name(name)
345        || is_model_role_name(name)
346        || is_agent_model_option_name(name)
347}
348
349fn is_secret_name(name: &str) -> bool {
350    name.strip_prefix("HARN_SECRET_")
351        .is_some_and(is_upper_identifier)
352}
353
354fn is_rate_limit_name(name: &str) -> bool {
355    let Some(suffix) = name.strip_prefix("HARN_RATE_LIMIT_") else {
356        return false;
357    };
358    let Some((provider, field)) = suffix.rsplit_once('_') else {
359        return false;
360    };
361    is_upper_identifier(provider) && matches!(field, "QUEUE" | "RPM" | "TPM" | "CONCURRENCY")
362}
363
364fn is_model_role_name(name: &str) -> bool {
365    let suffix = name
366        .strip_prefix("HARN_LLM_ROLE_")
367        .or_else(|| name.strip_prefix("HARN_LLM_"));
368    let Some(suffix) = suffix else {
369        return false;
370    };
371    ["_MODEL", "_PROVIDER", "_ROUTE_POLICY"]
372        .iter()
373        .find_map(|ending| suffix.strip_suffix(ending))
374        .is_some_and(is_upper_identifier)
375}
376
377/// `std/agent/options` derives role-specific configuration keys from a role
378/// token and a closed suffix vocabulary. Keep that dynamic reader family
379/// structural so custom roles do not require per-role registry entries.
380fn is_agent_model_option_name(name: &str) -> bool {
381    const SUFFIXES: &[&str] = &[
382        "_EFFORT",
383        "_MODEL",
384        "_MODEL_ROLE",
385        "_PROVIDER",
386        "_REASONING_TASK",
387        "_TOOL_FORMAT",
388    ];
389    let Some(prefix) = SUFFIXES.iter().find_map(|suffix| name.strip_suffix(suffix)) else {
390        return false;
391    };
392    let Some(prefix) = prefix.strip_prefix("HARN_") else {
393        return false;
394    };
395    let role = prefix
396        .strip_prefix("AGENT_")
397        .or_else(|| prefix.strip_prefix("LLM_"))
398        .unwrap_or(prefix);
399    matches!(prefix, "AGENT" | "LLM") || is_upper_identifier(role)
400}
401
402fn is_upper_identifier(value: &str) -> bool {
403    !value.is_empty()
404        && value
405            .bytes()
406            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
407        && !value.starts_with('_')
408        && !value.ends_with('_')
409}
410
411fn nearest_registered_name(name: &str) -> Option<String> {
412    registered_names()
413        .iter()
414        .copied()
415        .filter(|candidate| !candidate.ends_with('_'))
416        .map(|candidate| (strsim::levenshtein(name, candidate), candidate))
417        .min_by_key(|(distance, candidate)| (*distance, *candidate))
418        .filter(|(distance, _)| *distance <= 3)
419        .map(|(_, candidate)| candidate.to_string())
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn typo_is_typed_and_suggests_registered_name() {
428        let error = validate_environment([("HARN_LLM_TIMOUT", "30")]).unwrap_err();
429        assert_eq!(
430            error.diagnostics(),
431            &[EnvironmentDiagnostic {
432                code: UNKNOWN_CODE,
433                key: "HARN_LLM_TIMOUT".to_string(),
434                kind: EnvironmentDiagnosticKind::UnknownName {
435                    suggestion: Some("HARN_LLM_TIMEOUT".to_string()),
436                },
437            }]
438        );
439    }
440
441    #[test]
442    fn known_and_structured_extension_names_are_accepted() {
443        validate_environment([
444            ("HARN_LLM_TIMEOUT", "30"),
445            ("HARN_EXT_ACME_MODE", "custom"),
446            ("HARN_LLM_ROLE_REVIEW_MODEL", "reviewer"),
447            ("HARN_SECRET_ACME_TOKEN", "credential"),
448        ])
449        .unwrap();
450    }
451
452    #[test]
453    fn malformed_extension_name_is_not_a_prefix_bypass() {
454        let error = validate_environment([("HARN_EXT_", "anything")]).unwrap_err();
455        assert!(matches!(
456            error.diagnostics()[0].kind,
457            EnvironmentDiagnosticKind::UnknownName { .. }
458        ));
459    }
460
461    /// These names are composed at read time rather than written literally, so
462    /// no source scan can find them and the reverse drift gate cannot see them.
463    /// `std/agent/options` builds each key as one of the `HARN_AGENT`,
464    /// `HARN_LLM`, `HARN_AGENT_<ROLE>`, `HARN_LLM_<ROLE>`, or `HARN_<ROLE>`
465    /// prefixes joined to a closed suffix vocabulary, which is why the grammar
466    /// admits a name such as `HARN_LLM_TOOL_FORMAT` that grep alone would read
467    /// as a near miss for `HARN_AGENT_TOOL_FORMAT`.
468    #[test]
469    fn dynamic_agent_role_options_follow_a_closed_structural_grammar() {
470        for name in [
471            "HARN_AGENT_MODEL",
472            "HARN_LLM_TOOL_FORMAT",
473            "HARN_AGENT_REVIEW_PROVIDER",
474            "HARN_LLM_PLANNER_REASONING_TASK",
475            "HARN_RELEASE_EFFORT",
476        ] {
477            assert!(variable_spec(name).is_some(), "{name}");
478        }
479        for name in [
480            "HARN_AGENT_REVIEW_UNKNOWN",
481            "HARN_LLM_TIMOUT",
482            "HARN_RELEASE_",
483        ] {
484            assert!(variable_spec(name).is_none(), "{name}");
485        }
486    }
487
488    #[test]
489    fn credential_metadata_covers_non_api_oauth_keys() {
490        assert_eq!(
491            variable_spec("HARN_OAUTH_KEY").unwrap().sensitivity,
492            EnvironmentSensitivity::Credential
493        );
494    }
495
496    #[test]
497    fn invalid_known_value_is_rejected_at_startup() {
498        let error = validate_environment([("HARN_LLM_TIMEOUT", "soon")]).unwrap_err();
499        assert_eq!(
500            error.diagnostics()[0].kind,
501            EnvironmentDiagnosticKind::InvalidValue {
502                expected: EnvironmentValueShape::UnsignedInteger
503            }
504        );
505    }
506
507    #[test]
508    fn subtask_placement_uses_the_runtime_owned_closed_vocabulary() {
509        let spec = variable_spec(crate::vm::subtask::PLACEMENT_ENV).unwrap();
510        assert_eq!(
511            spec.value_shape,
512            EnvironmentValueShape::Enumerated(crate::vm::subtask::PLACEMENT_VALUES)
513        );
514        validate_environment([(crate::vm::subtask::PLACEMENT_ENV, "worker")]).unwrap();
515        validate_environment([(crate::vm::subtask::PLACEMENT_ENV, "CURRENT_THREAD")]).unwrap();
516
517        let error =
518            validate_environment([(crate::vm::subtask::PLACEMENT_ENV, "workers")]).unwrap_err();
519        assert_eq!(
520            error.diagnostics()[0].kind,
521            EnvironmentDiagnosticKind::InvalidValue {
522                expected: EnvironmentValueShape::Enumerated(crate::vm::subtask::PLACEMENT_VALUES)
523            }
524        );
525        assert!(error.to_string().contains("`worker`, `current_thread`"));
526    }
527
528    #[test]
529    fn diagnostics_cannot_render_values_even_for_credentials() {
530        let secret = "must-never-appear";
531        let error = validate_environment([("HARN_CLOUD_API_KEZ", secret)]).unwrap_err();
532        let rendered = error.to_string();
533        assert!(rendered.contains("HARN_CLOUD_API_KEZ"));
534        assert!(!rendered.contains(secret));
535    }
536
537    /// Dispatch refuses the retired budget names, so a run cannot treat them
538    /// as a live ceiling. Names are assembled without a `"HARN_` token so this
539    /// fixture is not a live reader.
540    #[test]
541    fn retired_limit_names_are_unknown_at_startup() {
542        for name in [
543            concat!("HARN", "_BUDGET_USD"),
544            concat!("HARN", "_TOKEN_BUDGET"),
545            concat!("HARN", "_MAX_CONCURRENCY"),
546            concat!("HARN", "_NETWORK_MODE"),
547            concat!("HARN", "_FILESYSTEM_MODE"),
548            concat!("HARN", "_SANDBOX_MODE"),
549        ] {
550            let error = validate_environment([(name, "1")]).expect_err(name);
551            assert!(
552                error.to_string().contains(name),
553                "startup must name {name}, got {error}"
554            );
555            assert!(
556                variable_spec(name).is_none(),
557                "{name} must stay unregistered"
558            );
559        }
560    }
561
562    #[test]
563    fn registry_is_sorted_unique_and_contains_metadata() {
564        let names = registered_names();
565        assert!(
566            names.windows(2).all(|pair| pair[0] < pair[1]),
567            "environment registry must remain sorted and unique"
568        );
569        let timeout = variable_spec("HARN_LLM_TIMEOUT").unwrap();
570        assert_eq!(timeout.consumer, EnvironmentConsumer::Runtime);
571        assert_eq!(timeout.value_shape, EnvironmentValueShape::UnsignedInteger);
572        let token = variable_spec("HARN_PACKAGE_REGISTRY_TOKEN").unwrap();
573        assert_eq!(token.sensitivity, EnvironmentSensitivity::Credential);
574        let host_providers = variable_spec(crate::llm_config::HOST_PROVIDERS_CONFIG_ENV).unwrap();
575        assert_eq!(host_providers.consumer, EnvironmentConsumer::Runtime);
576        assert_eq!(
577            host_providers.value_shape,
578            EnvironmentValueShape::OwnerValidated
579        );
580    }
581
582    #[test]
583    fn embedded_runtime_bootstrap_accepts_registered_process_environment() {
584        crate::initialize_runtime().expect("registered process environment");
585    }
586
587    #[test]
588    fn every_compiled_harn_name_is_registered_or_structurally_owned() {
589        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
590            .ancestors()
591            .nth(2)
592            .expect("harn-vm lives below workspace/crates");
593        let protocol_symbols = protocol_artifact_symbol_names(workspace_root);
594        let crates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
595            .parent()
596            .expect("harn-vm lives below crates");
597        let mut missing = std::collections::BTreeSet::new();
598        for entry in walkdir::WalkDir::new(crates_dir)
599            .into_iter()
600            .filter_map(Result::ok)
601            .filter(|entry| {
602                entry.file_type().is_file()
603                    && entry.path().extension().and_then(OsStr::to_str) == Some("rs")
604                    && entry
605                        .path()
606                        .components()
607                        .any(|component| component.as_os_str() == "src")
608                    && !is_protocol_artifact_projection(entry.path())
609                    && entry.file_name() != "environment_registry.rs"
610            })
611        {
612            let source = std::fs::read_to_string(entry.path()).expect("read Rust source");
613            for token in harn_name_tokens(&source) {
614                if variable_spec(token).is_none()
615                    && !protocol_symbols.contains(token)
616                    && !matches!(token, "HARN_LLM_" | "HARN_LLM_ROLE_" | "HARN_SECRET_")
617                {
618                    missing.insert(format!("{}: {token}", entry.path().display()));
619                }
620            }
621        }
622        assert!(
623            missing.is_empty(),
624            "compiled HARN_* names missing from environment_registry_names.txt:\n{}",
625            missing.into_iter().collect::<Vec<_>>().join("\n")
626        );
627    }
628
629    #[test]
630    fn every_harn_script_owned_name_is_registered_or_structurally_owned() {
631        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
632            .ancestors()
633            .nth(2)
634            .expect("harn-vm lives below workspace/crates");
635        let source_roots = [
636            "benchmarks",
637            "conformance",
638            "crates",
639            "evals",
640            "examples",
641            "experiments",
642            "perf",
643            "personas",
644            "scripts",
645            "tests",
646        ];
647        let protocol_symbols = protocol_artifact_symbol_names(workspace_root);
648        let mut missing = std::collections::BTreeSet::new();
649        for source_root in source_roots {
650            let source_root = workspace_root.join(source_root);
651            if !source_root.exists() {
652                continue;
653            }
654            for entry in walkdir::WalkDir::new(source_root)
655                .into_iter()
656                .filter_entry(|entry| !is_pruned_reference_directory(entry))
657                .filter_map(Result::ok)
658                .filter(|entry| {
659                    entry.file_type().is_file()
660                        && entry.path().extension().and_then(OsStr::to_str) == Some("harn")
661                })
662            {
663                let source = std::fs::read_to_string(entry.path()).expect("read Harn source");
664                for token in harn_name_tokens(&source) {
665                    // Bare prefixes, not names. Each is a literal that a script
666                    // concatenates a suffix onto, so the scanner sees the
667                    // prefix alone and cannot resolve the name that is actually
668                    // read. The names those expressions produce are registered
669                    // individually; only the unresolvable fragment is excused.
670                    if variable_spec(token).is_none()
671                        && !protocol_symbols.contains(token)
672                        && !matches!(
673                            token,
674                            "HARN_AGENT"
675                                | "HARN_AGENT_"
676                                | "HARN_BOOTSTRAP_"
677                                | "HARN_LLM"
678                                | "HARN_LLM_"
679                                | "HARN_PLANNER"
680                                | "HARN_RELEASE"
681                        )
682                    {
683                        missing.insert(format!("{}: {token}", entry.path().display()));
684                    }
685                }
686            }
687        }
688        assert!(
689            missing.is_empty(),
690            "Harn-script HARN_* names missing from environment_registry_names.txt:\n{}",
691            missing.into_iter().collect::<Vec<_>>().join("\n")
692        );
693    }
694
695    /// The `HARN_*` names a workflow file ASSIGNS.
696    ///
697    /// Only the assigning forms count. A name that is merely referenced cannot
698    /// put itself into a child process's environment, so it cannot trip
699    /// `validate_startup_environment`; a name a workflow sets can, and will.
700    /// `harn_name_tokens` cannot serve here because it anchors on `"HARN_`,
701    /// and a YAML mapping key is bare.
702    fn workflow_assigned_names(source: &str) -> std::collections::BTreeSet<String> {
703        let mut names = std::collections::BTreeSet::new();
704        for line in source.lines() {
705            let trimmed = line.trim_start();
706            let candidate = trimmed.strip_prefix("export ").unwrap_or(trimmed);
707            let Some(rest) = candidate.strip_prefix("HARN_") else {
708                continue;
709            };
710            let mut name = String::from("HARN_");
711            let mut terminator = None;
712            for character in rest.chars() {
713                if character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'
714                {
715                    name.push(character);
716                } else {
717                    terminator = Some(character);
718                    break;
719                }
720            }
721            if name == "HARN_" {
722                continue;
723            }
724            // `NAME:` is a YAML mapping key and `NAME=` is a shell assignment.
725            // Anything else is prose, a reference on a right-hand side, or a
726            // bare token that assigns nothing.
727            if !matches!(terminator, Some(':') | Some('=')) {
728                continue;
729            }
730            names.insert(name);
731        }
732        names
733    }
734
735    #[test]
736    fn workflow_name_scan_reads_assignments_and_ignores_references() {
737        let source = concat!(
738            "        env:\n",
739            "          HARN_BUMP_BRANCH: main\n",
740            "        run: |\n",
741            "          export HARN_BUMP_REFRESH=\"$HARN_BUMP_REFRESH_COMMAND\"\n",
742            "          echo \"$HARN_BUMP_TOKEN\"\n",
743            "          # HARN_BUMP_NOT_A_KEY: prose\n",
744        );
745        let names = workflow_assigned_names(source);
746        assert!(names.contains("HARN_BUMP_BRANCH"), "reads a YAML env key");
747        assert!(names.contains("HARN_BUMP_REFRESH"), "reads a shell export");
748        assert!(
749            !names.contains("HARN_BUMP_REFRESH_COMMAND"),
750            "a reference on a right-hand side is not an assignment"
751        );
752        assert!(
753            !names.contains("HARN_BUMP_TOKEN"),
754            "an echoed reference is not an assignment"
755        );
756        assert!(
757            !names.contains("HARN_BUMP_NOT_A_KEY"),
758            "a comment is not an assignment"
759        );
760    }
761
762    /// A workflow that sets an unregistered `HARN_*` name ships a job that dies
763    /// on `HARN-ENV-001` the first time it starts a Harn process. The compiled
764    /// and Harn-script censuses above never see it, because a workflow is
765    /// neither. This closes that hole.
766    #[test]
767    fn every_workflow_assigned_name_is_registered() {
768        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
769            .ancestors()
770            .nth(2)
771            .expect("harn-vm lives below workspace/crates");
772        let workflows = workspace_root.join(".github/workflows");
773        assert!(
774            workflows.is_dir(),
775            "no workflow directory at {}; this gate would pass by seeing nothing",
776            workflows.display()
777        );
778        let mut scanned = 0usize;
779        let mut missing = std::collections::BTreeSet::new();
780        for entry in walkdir::WalkDir::new(&workflows)
781            .into_iter()
782            .filter_map(Result::ok)
783            .filter(|entry| {
784                entry.file_type().is_file()
785                    && matches!(
786                        entry.path().extension().and_then(OsStr::to_str),
787                        Some("yml") | Some("yaml")
788                    )
789            })
790        {
791            let source = std::fs::read_to_string(entry.path()).expect("read workflow");
792            scanned += 1;
793            for name in workflow_assigned_names(&source) {
794                if variable_spec(&name).is_none() {
795                    missing.insert(format!("{}: {name}", entry.path().display()));
796                }
797            }
798        }
799        assert!(
800            scanned > 0,
801            "scanned no workflow files; the gate cannot see anything"
802        );
803        assert!(
804            missing.is_empty(),
805            "workflow-assigned HARN_* names missing from \
806             environment_registry_names.txt:\n{}",
807            missing.into_iter().collect::<Vec<_>>().join("\n")
808        );
809    }
810
811    /// Registered names that have no repository-owned reference, and why they must
812    /// stay. Every entry is a standing exception to the reverse drift gate, so
813    /// it carries the reason a source reference cannot exist in this tree. An
814    /// entry that gains an owner is rejected too, which keeps the list from
815    /// rotting.
816    const UNREAD_NAME_ALLOWLIST: &[(&str, &str)] = &[];
817
818    /// Build output, caches, vendored dependencies, and nested checkouts are
819    /// not sources of truth for variable ownership. A nested worktree is the
820    /// dangerous one: it carries its own copy of the registry and literals, so
821    /// walking into it would make any name look alive.
822    fn is_pruned_reference_directory(entry: &walkdir::DirEntry) -> bool {
823        if !entry.file_type().is_dir() {
824            return false;
825        }
826        let Some(name) = entry.file_name().to_str() else {
827            return true;
828        };
829        name.starts_with(".target")
830            || matches!(
831                name,
832                ".build"
833                    | ".burin"
834                    | ".git"
835                    | ".harn"
836                    | ".harn-runs"
837                    | ".harn-toolchain-cache"
838                    | ".venv"
839                    | ".worktrees"
840                    | "__pycache__"
841                    | "changelog"
842                    | "dist"
843                    | "node_modules"
844                    | "pkg"
845                    | "target"
846            )
847    }
848
849    #[test]
850    fn generated_harn_state_is_not_an_environment_contract_owner() {
851        let root = tempfile::tempdir().expect("temporary workspace");
852        let state = root.path().join(".harn");
853        std::fs::create_dir(&state).expect("create generated Harn state");
854        let entry = walkdir::WalkDir::new(root.path())
855            .into_iter()
856            .filter_map(Result::ok)
857            .find(|entry| entry.path() == state)
858            .expect("walk generated Harn state");
859
860        assert!(is_pruned_reference_directory(&entry));
861    }
862
863    /// Generated protocol names describe wire contracts; they are not process
864    /// environment reads. Exclude both their generator and generated output so
865    /// a public constant cannot accidentally keep an environment variable alive.
866    fn is_protocol_artifact_projection(path: &std::path::Path) -> bool {
867        path.components().any(|component| {
868            matches!(
869                component.as_os_str().to_str(),
870                Some("dump_protocol_artifacts" | "protocol-artifacts")
871            )
872        })
873    }
874
875    /// Prose can mention a retired name forever, so release notes and docs do
876    /// not count as owners. Registries and protocol projections are excluded
877    /// for the same reason: listing a name is not an environment contract.
878    fn is_reference_source(path: &std::path::Path) -> bool {
879        if path.extension().and_then(OsStr::to_str) == Some("md")
880            || is_protocol_artifact_projection(path)
881        {
882            return false;
883        }
884        !matches!(
885            path.file_name().and_then(OsStr::to_str),
886            Some("environment_registry.rs" | "environment_registry_names.txt")
887        )
888    }
889
890    /// The generated Rust projection is the authority for public protocol
891    /// symbol names. Deriving this set keeps the environment registry from
892    /// maintaining a second list of `HARN_*` constants.
893    fn protocol_artifact_symbol_names(
894        workspace_root: &std::path::Path,
895    ) -> std::collections::BTreeSet<String> {
896        let path = workspace_root.join("spec/protocol-artifacts/harn-protocol.rs");
897        let source = std::fs::read_to_string(&path)
898            .unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
899        bounded_harn_tokens(&source).map(str::to_string).collect()
900    }
901
902    #[test]
903    fn protocol_artifact_names_are_not_environment_references() {
904        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
905            .ancestors()
906            .nth(2)
907            .expect("harn-vm lives below workspace/crates");
908        let protocol_symbols = protocol_artifact_symbol_names(workspace_root);
909        let generator = std::path::Path::new(
910            "crates/harn-cli/src/commands/dump_protocol_artifacts/typescript.rs",
911        );
912        let projection = std::path::Path::new("spec/protocol-artifacts/harn-protocol.ts");
913        let runtime = std::path::Path::new("crates/harn-vm/src/llm/call.rs");
914
915        assert!(is_protocol_artifact_projection(generator));
916        assert!(is_protocol_artifact_projection(projection));
917        assert!(!is_reference_source(generator));
918        assert!(!is_reference_source(projection));
919        assert!(is_reference_source(runtime));
920        assert!(variable_spec("HARN_AGENT_EVENT_KINDS").is_none());
921        assert!(variable_spec("HARN_WORKER_STATUSES").is_none());
922        assert!(protocol_symbols.contains("HARN_AGENT_EVENT_KINDS"));
923        assert!(!protocol_symbols.contains("HARN_ACP_VERBOSE"));
924    }
925
926    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
927    enum EnvironmentReferenceSyntax {
928        StringLiteral,
929        ShellLike,
930    }
931
932    fn environment_reference_syntax(path: &std::path::Path) -> EnvironmentReferenceSyntax {
933        let file_name = path.file_name().and_then(OsStr::to_str).unwrap_or_default();
934        let extension = path.extension().and_then(OsStr::to_str).unwrap_or_default();
935        if matches!(file_name, "Makefile" | "GNUmakefile")
936            || file_name.starts_with(".env")
937            || matches!(
938                extension,
939                "bash" | "bat" | "cmd" | "fish" | "mk" | "ps1" | "sh" | "yaml" | "yml" | "zsh"
940            )
941        {
942            EnvironmentReferenceSyntax::ShellLike
943        } else {
944            EnvironmentReferenceSyntax::StringLiteral
945        }
946    }
947
948    fn environment_reference_tokens<'a>(path: &std::path::Path, source: &'a str) -> Vec<&'a str> {
949        match environment_reference_syntax(path) {
950            EnvironmentReferenceSyntax::StringLiteral => harn_name_tokens(source).collect(),
951            EnvironmentReferenceSyntax::ShellLike => bounded_harn_tokens(source).collect(),
952        }
953    }
954
955    /// Every maximal `HARN_*` token that starts at an identifier boundary.
956    /// Shell, Make, and YAML reference variables without quoting their names;
957    /// compiled languages use the literal-only scanner instead.
958    #[expect(
959        clippy::string_slice,
960        reason = "start/end bound an ASCII HARN_* token found by match_indices"
961    )]
962    fn bounded_harn_tokens(source: &str) -> impl Iterator<Item = &str> {
963        let bytes = source.as_bytes();
964        source.match_indices("HARN_").filter_map(move |(start, _)| {
965            if start > 0 {
966                let previous = bytes[start - 1];
967                if previous.is_ascii_alphanumeric() || previous == b'_' {
968                    return None;
969                }
970            }
971            let mut end = start + "HARN_".len();
972            while end < bytes.len()
973                && (bytes[end].is_ascii_uppercase()
974                    || bytes[end].is_ascii_digit()
975                    || bytes[end] == b'_')
976            {
977                end += 1;
978            }
979            Some(&source[start..end])
980        })
981    }
982
983    /// The forward gates prove that every environment-shaped source reference
984    /// is registered. The reverse direction rejects rows whose owner vanished,
985    /// while generated protocol identifiers cannot keep unrelated environment
986    /// knobs alive in compiled languages.
987    #[test]
988    fn every_registered_name_has_a_non_projection_source_reference() {
989        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
990            .ancestors()
991            .nth(2)
992            .expect("harn-vm lives below workspace/crates");
993        let mut referenced = std::collections::BTreeSet::new();
994        let protocol_symbols = protocol_artifact_symbol_names(workspace_root);
995        for entry in walkdir::WalkDir::new(workspace_root)
996            .into_iter()
997            .filter_entry(|entry| !is_pruned_reference_directory(entry))
998            .filter_map(Result::ok)
999            .filter(|entry| entry.file_type().is_file() && is_reference_source(entry.path()))
1000        {
1001            let Ok(source) = std::fs::read_to_string(entry.path()) else {
1002                continue;
1003            };
1004            for token in environment_reference_tokens(entry.path(), &source) {
1005                if !protocol_symbols.contains(token) {
1006                    referenced.insert(token.to_string());
1007                }
1008            }
1009        }
1010
1011        let allowed: std::collections::BTreeSet<&str> = UNREAD_NAME_ALLOWLIST
1012            .iter()
1013            .map(|(name, _)| *name)
1014            .collect();
1015        let unregistered_allowlist = allowed
1016            .iter()
1017            .copied()
1018            .filter(|name| registered_names().binary_search(name).is_err())
1019            .collect::<Vec<_>>();
1020        assert!(
1021            unregistered_allowlist.is_empty(),
1022            "allowlisted names are not in environment_registry_names.txt:\n{}",
1023            unregistered_allowlist.join("\n")
1024        );
1025        let owned_allowlist = allowed
1026            .iter()
1027            .copied()
1028            .filter(|name| referenced.contains(*name))
1029            .collect::<Vec<_>>();
1030        assert!(
1031            owned_allowlist.is_empty(),
1032            "allowlisted names now have source owners; drop them from UNREAD_NAME_ALLOWLIST:\n{}",
1033            owned_allowlist.join("\n")
1034        );
1035
1036        let unread = registered_names()
1037            .iter()
1038            .copied()
1039            .filter(|name| !referenced.contains(*name) && !allowed.contains(name))
1040            .collect::<Vec<_>>();
1041        assert!(
1042            unread.is_empty(),
1043            "registered names have no source owner; delete them from \
1044             environment_registry_names.txt or allowlist them with a reason:\n{}",
1045            unread.join("\n")
1046        );
1047    }
1048
1049    #[test]
1050    fn environment_reference_scan_dispatches_by_source_syntax() {
1051        let compiled = concat!(
1052            "const HARN_AGENT_EVENT_KINDS: &[&str] = &[];\n",
1053            "const ENV: &str = \"HARN_REAL_ENVIRONMENT_KNOB\";\n",
1054        );
1055        assert_eq!(
1056            environment_reference_tokens(std::path::Path::new("runtime.rs"), compiled),
1057            vec!["HARN_REAL_ENVIRONMENT_KNOB"]
1058        );
1059        assert_eq!(
1060            environment_reference_tokens(
1061                std::path::Path::new("bench.sh"),
1062                "cache=${HARN_BENCH_CACHE_DIR:-target}\n",
1063            ),
1064            vec!["HARN_BENCH_CACHE_DIR"]
1065        );
1066        assert_eq!(
1067            environment_reference_tokens(
1068                std::path::Path::new("Makefile"),
1069                "HARN_BIN_ASSIGN = harn_bin\n",
1070            ),
1071            vec!["HARN_BIN_ASSIGN"]
1072        );
1073    }
1074
1075    #[expect(
1076        clippy::string_slice,
1077        reason = "start/end bound an ASCII HARN_* token found by match_indices"
1078    )]
1079    fn harn_name_tokens(source: &str) -> impl Iterator<Item = &str> {
1080        source.match_indices("\"HARN_").filter_map(|(quote, _)| {
1081            let start = quote + 1;
1082            let bytes = source.as_bytes();
1083            let mut end = start + "HARN_".len();
1084            while end < bytes.len()
1085                && (bytes[end].is_ascii_uppercase()
1086                    || bytes[end].is_ascii_digit()
1087                    || bytes[end] == b'_')
1088            {
1089                end += 1;
1090            }
1091            (end > start + "HARN_".len()).then(|| &source[start..end])
1092        })
1093    }
1094}