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