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_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    #[test]
538    fn registry_is_sorted_unique_and_contains_metadata() {
539        let names = registered_names();
540        assert!(
541            names.windows(2).all(|pair| pair[0] < pair[1]),
542            "environment registry must remain sorted and unique"
543        );
544        let timeout = variable_spec("HARN_LLM_TIMEOUT").unwrap();
545        assert_eq!(timeout.consumer, EnvironmentConsumer::Runtime);
546        assert_eq!(timeout.value_shape, EnvironmentValueShape::UnsignedInteger);
547        let token = variable_spec("HARN_PACKAGE_REGISTRY_TOKEN").unwrap();
548        assert_eq!(token.sensitivity, EnvironmentSensitivity::Credential);
549        let host_providers = variable_spec(crate::llm_config::HOST_PROVIDERS_CONFIG_ENV).unwrap();
550        assert_eq!(host_providers.consumer, EnvironmentConsumer::Runtime);
551        assert_eq!(
552            host_providers.value_shape,
553            EnvironmentValueShape::OwnerValidated
554        );
555    }
556
557    #[test]
558    fn embedded_runtime_bootstrap_accepts_registered_process_environment() {
559        crate::initialize_runtime().expect("registered process environment");
560    }
561
562    #[test]
563    fn every_compiled_harn_name_is_registered_or_structurally_owned() {
564        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
565            .ancestors()
566            .nth(2)
567            .expect("harn-vm lives below workspace/crates");
568        let protocol_symbols = protocol_artifact_symbol_names(workspace_root);
569        let crates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
570            .parent()
571            .expect("harn-vm lives below crates");
572        let mut missing = std::collections::BTreeSet::new();
573        for entry in walkdir::WalkDir::new(crates_dir)
574            .into_iter()
575            .filter_map(Result::ok)
576            .filter(|entry| {
577                entry.file_type().is_file()
578                    && entry.path().extension().and_then(OsStr::to_str) == Some("rs")
579                    && entry
580                        .path()
581                        .components()
582                        .any(|component| component.as_os_str() == "src")
583                    && !is_protocol_artifact_projection(entry.path())
584                    && entry.file_name() != "environment_registry.rs"
585            })
586        {
587            let source = std::fs::read_to_string(entry.path()).expect("read Rust source");
588            for token in harn_name_tokens(&source) {
589                if variable_spec(token).is_none()
590                    && !protocol_symbols.contains(token)
591                    && !matches!(token, "HARN_LLM_" | "HARN_LLM_ROLE_" | "HARN_SECRET_")
592                {
593                    missing.insert(format!("{}: {token}", entry.path().display()));
594                }
595            }
596        }
597        assert!(
598            missing.is_empty(),
599            "compiled HARN_* names missing from environment_registry_names.txt:\n{}",
600            missing.into_iter().collect::<Vec<_>>().join("\n")
601        );
602    }
603
604    #[test]
605    fn every_harn_script_owned_name_is_registered_or_structurally_owned() {
606        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
607            .ancestors()
608            .nth(2)
609            .expect("harn-vm lives below workspace/crates");
610        let source_roots = [
611            "benchmarks",
612            "conformance",
613            "crates",
614            "evals",
615            "examples",
616            "experiments",
617            "perf",
618            "personas",
619            "scripts",
620            "tests",
621        ];
622        let protocol_symbols = protocol_artifact_symbol_names(workspace_root);
623        let mut missing = std::collections::BTreeSet::new();
624        for source_root in source_roots {
625            let source_root = workspace_root.join(source_root);
626            if !source_root.exists() {
627                continue;
628            }
629            for entry in walkdir::WalkDir::new(source_root)
630                .into_iter()
631                .filter_entry(|entry| !is_pruned_reference_directory(entry))
632                .filter_map(Result::ok)
633                .filter(|entry| {
634                    entry.file_type().is_file()
635                        && entry.path().extension().and_then(OsStr::to_str) == Some("harn")
636                })
637            {
638                let source = std::fs::read_to_string(entry.path()).expect("read Harn source");
639                for token in harn_name_tokens(&source) {
640                    if variable_spec(token).is_none()
641                        && !protocol_symbols.contains(token)
642                        && !matches!(
643                            token,
644                            "HARN_AGENT"
645                                | "HARN_AGENT_"
646                                | "HARN_LLM"
647                                | "HARN_LLM_"
648                                | "HARN_PLANNER"
649                                | "HARN_RELEASE"
650                        )
651                    {
652                        missing.insert(format!("{}: {token}", entry.path().display()));
653                    }
654                }
655            }
656        }
657        assert!(
658            missing.is_empty(),
659            "Harn-script HARN_* names missing from environment_registry_names.txt:\n{}",
660            missing.into_iter().collect::<Vec<_>>().join("\n")
661        );
662    }
663
664    /// The `HARN_*` names a workflow file ASSIGNS.
665    ///
666    /// Only the assigning forms count. A name that is merely referenced cannot
667    /// put itself into a child process's environment, so it cannot trip
668    /// `validate_startup_environment`; a name a workflow sets can, and will.
669    /// `harn_name_tokens` cannot serve here because it anchors on `"HARN_`,
670    /// and a YAML mapping key is bare.
671    fn workflow_assigned_names(source: &str) -> std::collections::BTreeSet<String> {
672        let mut names = std::collections::BTreeSet::new();
673        for line in source.lines() {
674            let trimmed = line.trim_start();
675            let candidate = trimmed.strip_prefix("export ").unwrap_or(trimmed);
676            let Some(rest) = candidate.strip_prefix("HARN_") else {
677                continue;
678            };
679            let mut name = String::from("HARN_");
680            let mut terminator = None;
681            for character in rest.chars() {
682                if character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'
683                {
684                    name.push(character);
685                } else {
686                    terminator = Some(character);
687                    break;
688                }
689            }
690            if name == "HARN_" {
691                continue;
692            }
693            // `NAME:` is a YAML mapping key and `NAME=` is a shell assignment.
694            // Anything else is prose, a reference on a right-hand side, or a
695            // bare token that assigns nothing.
696            if !matches!(terminator, Some(':') | Some('=')) {
697                continue;
698            }
699            names.insert(name);
700        }
701        names
702    }
703
704    #[test]
705    fn workflow_name_scan_reads_assignments_and_ignores_references() {
706        let source = concat!(
707            "        env:\n",
708            "          HARN_BUMP_BRANCH: main\n",
709            "        run: |\n",
710            "          export HARN_BUMP_REFRESH=\"$HARN_BUMP_REFRESH_COMMAND\"\n",
711            "          echo \"$HARN_BUMP_TOKEN\"\n",
712            "          # HARN_BUMP_NOT_A_KEY: prose\n",
713        );
714        let names = workflow_assigned_names(source);
715        assert!(names.contains("HARN_BUMP_BRANCH"), "reads a YAML env key");
716        assert!(names.contains("HARN_BUMP_REFRESH"), "reads a shell export");
717        assert!(
718            !names.contains("HARN_BUMP_REFRESH_COMMAND"),
719            "a reference on a right-hand side is not an assignment"
720        );
721        assert!(
722            !names.contains("HARN_BUMP_TOKEN"),
723            "an echoed reference is not an assignment"
724        );
725        assert!(
726            !names.contains("HARN_BUMP_NOT_A_KEY"),
727            "a comment is not an assignment"
728        );
729    }
730
731    /// A workflow that sets an unregistered `HARN_*` name ships a job that dies
732    /// on `HARN-ENV-001` the first time it starts a Harn process. The compiled
733    /// and Harn-script censuses above never see it, because a workflow is
734    /// neither. This closes that hole.
735    #[test]
736    fn every_workflow_assigned_name_is_registered() {
737        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
738            .ancestors()
739            .nth(2)
740            .expect("harn-vm lives below workspace/crates");
741        let workflows = workspace_root.join(".github/workflows");
742        assert!(
743            workflows.is_dir(),
744            "no workflow directory at {}; this gate would pass by seeing nothing",
745            workflows.display()
746        );
747        let mut scanned = 0usize;
748        let mut missing = std::collections::BTreeSet::new();
749        for entry in walkdir::WalkDir::new(&workflows)
750            .into_iter()
751            .filter_map(Result::ok)
752            .filter(|entry| {
753                entry.file_type().is_file()
754                    && matches!(
755                        entry.path().extension().and_then(OsStr::to_str),
756                        Some("yml") | Some("yaml")
757                    )
758            })
759        {
760            let source = std::fs::read_to_string(entry.path()).expect("read workflow");
761            scanned += 1;
762            for name in workflow_assigned_names(&source) {
763                if variable_spec(&name).is_none() {
764                    missing.insert(format!("{}: {name}", entry.path().display()));
765                }
766            }
767        }
768        assert!(
769            scanned > 0,
770            "scanned no workflow files; the gate cannot see anything"
771        );
772        assert!(
773            missing.is_empty(),
774            "workflow-assigned HARN_* names missing from \
775             environment_registry_names.txt:\n{}",
776            missing.into_iter().collect::<Vec<_>>().join("\n")
777        );
778    }
779
780    /// Registered names that have no repository-owned reference, and why they must
781    /// stay. Every entry is a standing exception to the reverse drift gate, so
782    /// it carries the reason a source reference cannot exist in this tree. An
783    /// entry that gains an owner is rejected too, which keeps the list from
784    /// rotting.
785    const UNREAD_NAME_ALLOWLIST: &[(&str, &str)] = &[];
786
787    /// Build output, caches, vendored dependencies, and nested checkouts are
788    /// not sources of truth for variable ownership. A nested worktree is the
789    /// dangerous one: it carries its own copy of the registry and literals, so
790    /// walking into it would make any name look alive.
791    fn is_pruned_reference_directory(entry: &walkdir::DirEntry) -> bool {
792        if !entry.file_type().is_dir() {
793            return false;
794        }
795        let Some(name) = entry.file_name().to_str() else {
796            return true;
797        };
798        name.starts_with(".target")
799            || matches!(
800                name,
801                ".build"
802                    | ".burin"
803                    | ".git"
804                    | ".harn"
805                    | ".harn-runs"
806                    | ".harn-toolchain-cache"
807                    | ".venv"
808                    | ".worktrees"
809                    | "__pycache__"
810                    | "changelog"
811                    | "dist"
812                    | "node_modules"
813                    | "pkg"
814                    | "target"
815            )
816    }
817
818    #[test]
819    fn generated_harn_state_is_not_an_environment_contract_owner() {
820        let root = tempfile::tempdir().expect("temporary workspace");
821        let state = root.path().join(".harn");
822        std::fs::create_dir(&state).expect("create generated Harn state");
823        let entry = walkdir::WalkDir::new(root.path())
824            .into_iter()
825            .filter_map(Result::ok)
826            .find(|entry| entry.path() == state)
827            .expect("walk generated Harn state");
828
829        assert!(is_pruned_reference_directory(&entry));
830    }
831
832    /// Generated protocol names describe wire contracts; they are not process
833    /// environment reads. Exclude both their generator and generated output so
834    /// a public constant cannot accidentally keep an environment variable alive.
835    fn is_protocol_artifact_projection(path: &std::path::Path) -> bool {
836        path.components().any(|component| {
837            matches!(
838                component.as_os_str().to_str(),
839                Some("dump_protocol_artifacts" | "protocol-artifacts")
840            )
841        })
842    }
843
844    /// Prose can mention a retired name forever, so release notes and docs do
845    /// not count as owners. Registries and protocol projections are excluded
846    /// for the same reason: listing a name is not an environment contract.
847    fn is_reference_source(path: &std::path::Path) -> bool {
848        if path.extension().and_then(OsStr::to_str) == Some("md")
849            || is_protocol_artifact_projection(path)
850        {
851            return false;
852        }
853        !matches!(
854            path.file_name().and_then(OsStr::to_str),
855            Some("environment_registry.rs" | "environment_registry_names.txt")
856        )
857    }
858
859    /// The generated Rust projection is the authority for public protocol
860    /// symbol names. Deriving this set keeps the environment registry from
861    /// maintaining a second list of `HARN_*` constants.
862    fn protocol_artifact_symbol_names(
863        workspace_root: &std::path::Path,
864    ) -> std::collections::BTreeSet<String> {
865        let path = workspace_root.join("spec/protocol-artifacts/harn-protocol.rs");
866        let source = std::fs::read_to_string(&path)
867            .unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
868        bounded_harn_tokens(&source).map(str::to_string).collect()
869    }
870
871    #[test]
872    fn protocol_artifact_names_are_not_environment_references() {
873        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
874            .ancestors()
875            .nth(2)
876            .expect("harn-vm lives below workspace/crates");
877        let protocol_symbols = protocol_artifact_symbol_names(workspace_root);
878        let generator = std::path::Path::new(
879            "crates/harn-cli/src/commands/dump_protocol_artifacts/typescript.rs",
880        );
881        let projection = std::path::Path::new("spec/protocol-artifacts/harn-protocol.ts");
882        let runtime = std::path::Path::new("crates/harn-vm/src/llm/call.rs");
883
884        assert!(is_protocol_artifact_projection(generator));
885        assert!(is_protocol_artifact_projection(projection));
886        assert!(!is_reference_source(generator));
887        assert!(!is_reference_source(projection));
888        assert!(is_reference_source(runtime));
889        assert!(variable_spec("HARN_AGENT_EVENT_KINDS").is_none());
890        assert!(variable_spec("HARN_WORKER_STATUSES").is_none());
891        assert!(protocol_symbols.contains("HARN_PROTOCOL_ARTIFACT_VERSION"));
892        assert!(protocol_symbols.contains("HARN_AGENT_EVENT_KINDS"));
893        assert!(!protocol_symbols.contains("HARN_ACP_VERBOSE"));
894    }
895
896    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
897    enum EnvironmentReferenceSyntax {
898        StringLiteral,
899        ShellLike,
900    }
901
902    fn environment_reference_syntax(path: &std::path::Path) -> EnvironmentReferenceSyntax {
903        let file_name = path.file_name().and_then(OsStr::to_str).unwrap_or_default();
904        let extension = path.extension().and_then(OsStr::to_str).unwrap_or_default();
905        if matches!(file_name, "Makefile" | "GNUmakefile")
906            || file_name.starts_with(".env")
907            || matches!(
908                extension,
909                "bash" | "bat" | "cmd" | "fish" | "mk" | "ps1" | "sh" | "yaml" | "yml" | "zsh"
910            )
911        {
912            EnvironmentReferenceSyntax::ShellLike
913        } else {
914            EnvironmentReferenceSyntax::StringLiteral
915        }
916    }
917
918    fn environment_reference_tokens<'a>(path: &std::path::Path, source: &'a str) -> Vec<&'a str> {
919        match environment_reference_syntax(path) {
920            EnvironmentReferenceSyntax::StringLiteral => harn_name_tokens(source).collect(),
921            EnvironmentReferenceSyntax::ShellLike => bounded_harn_tokens(source).collect(),
922        }
923    }
924
925    /// Every maximal `HARN_*` token that starts at an identifier boundary.
926    /// Shell, Make, and YAML reference variables without quoting their names;
927    /// compiled languages use the literal-only scanner instead.
928    #[expect(
929        clippy::string_slice,
930        reason = "start/end bound an ASCII HARN_* token found by match_indices"
931    )]
932    fn bounded_harn_tokens(source: &str) -> impl Iterator<Item = &str> {
933        let bytes = source.as_bytes();
934        source.match_indices("HARN_").filter_map(move |(start, _)| {
935            if start > 0 {
936                let previous = bytes[start - 1];
937                if previous.is_ascii_alphanumeric() || previous == b'_' {
938                    return None;
939                }
940            }
941            let mut end = start + "HARN_".len();
942            while end < bytes.len()
943                && (bytes[end].is_ascii_uppercase()
944                    || bytes[end].is_ascii_digit()
945                    || bytes[end] == b'_')
946            {
947                end += 1;
948            }
949            Some(&source[start..end])
950        })
951    }
952
953    /// The forward gates prove that every environment-shaped source reference
954    /// is registered. The reverse direction rejects rows whose owner vanished,
955    /// while generated protocol identifiers cannot keep unrelated environment
956    /// knobs alive in compiled languages.
957    #[test]
958    fn every_registered_name_has_a_non_projection_source_reference() {
959        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
960            .ancestors()
961            .nth(2)
962            .expect("harn-vm lives below workspace/crates");
963        let mut referenced = std::collections::BTreeSet::new();
964        let protocol_symbols = protocol_artifact_symbol_names(workspace_root);
965        for entry in walkdir::WalkDir::new(workspace_root)
966            .into_iter()
967            .filter_entry(|entry| !is_pruned_reference_directory(entry))
968            .filter_map(Result::ok)
969            .filter(|entry| entry.file_type().is_file() && is_reference_source(entry.path()))
970        {
971            let Ok(source) = std::fs::read_to_string(entry.path()) else {
972                continue;
973            };
974            for token in environment_reference_tokens(entry.path(), &source) {
975                if !protocol_symbols.contains(token) {
976                    referenced.insert(token.to_string());
977                }
978            }
979        }
980
981        let allowed: std::collections::BTreeSet<&str> = UNREAD_NAME_ALLOWLIST
982            .iter()
983            .map(|(name, _)| *name)
984            .collect();
985        let unregistered_allowlist = allowed
986            .iter()
987            .copied()
988            .filter(|name| registered_names().binary_search(name).is_err())
989            .collect::<Vec<_>>();
990        assert!(
991            unregistered_allowlist.is_empty(),
992            "allowlisted names are not in environment_registry_names.txt:\n{}",
993            unregistered_allowlist.join("\n")
994        );
995        let owned_allowlist = allowed
996            .iter()
997            .copied()
998            .filter(|name| referenced.contains(*name))
999            .collect::<Vec<_>>();
1000        assert!(
1001            owned_allowlist.is_empty(),
1002            "allowlisted names now have source owners; drop them from UNREAD_NAME_ALLOWLIST:\n{}",
1003            owned_allowlist.join("\n")
1004        );
1005
1006        let unread = registered_names()
1007            .iter()
1008            .copied()
1009            .filter(|name| !referenced.contains(*name) && !allowed.contains(name))
1010            .collect::<Vec<_>>();
1011        assert!(
1012            unread.is_empty(),
1013            "registered names have no source owner; delete them from \
1014             environment_registry_names.txt or allowlist them with a reason:\n{}",
1015            unread.join("\n")
1016        );
1017    }
1018
1019    #[test]
1020    fn environment_reference_scan_dispatches_by_source_syntax() {
1021        let compiled = concat!(
1022            "const HARN_AGENT_EVENT_KINDS: &[&str] = &[];\n",
1023            "const ENV: &str = \"HARN_REAL_ENVIRONMENT_KNOB\";\n",
1024        );
1025        assert_eq!(
1026            environment_reference_tokens(std::path::Path::new("runtime.rs"), compiled),
1027            vec!["HARN_REAL_ENVIRONMENT_KNOB"]
1028        );
1029        assert_eq!(
1030            environment_reference_tokens(
1031                std::path::Path::new("bench.sh"),
1032                "cache=${HARN_BENCH_CACHE_DIR:-target}\n",
1033            ),
1034            vec!["HARN_BENCH_CACHE_DIR"]
1035        );
1036        assert_eq!(
1037            environment_reference_tokens(
1038                std::path::Path::new("Makefile"),
1039                "HARN_BIN_ASSIGN = harn_bin\n",
1040            ),
1041            vec!["HARN_BIN_ASSIGN"]
1042        );
1043    }
1044
1045    #[expect(
1046        clippy::string_slice,
1047        reason = "start/end bound an ASCII HARN_* token found by match_indices"
1048    )]
1049    fn harn_name_tokens(source: &str) -> impl Iterator<Item = &str> {
1050        source.match_indices("\"HARN_").filter_map(|(quote, _)| {
1051            let start = quote + 1;
1052            let bytes = source.as_bytes();
1053            let mut end = start + "HARN_".len();
1054            while end < bytes.len()
1055                && (bytes[end].is_ascii_uppercase()
1056                    || bytes[end].is_ascii_digit()
1057                    || bytes[end] == b'_')
1058            {
1059                end += 1;
1060            }
1061            (end > start + "HARN_".len()).then(|| &source[start..end])
1062        })
1063    }
1064}