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    #[test]
444    fn dynamic_agent_role_options_follow_a_closed_structural_grammar() {
445        for name in [
446            "HARN_AGENT_MODEL",
447            "HARN_LLM_TOOL_FORMAT",
448            "HARN_AGENT_REVIEW_PROVIDER",
449            "HARN_LLM_PLANNER_REASONING_TASK",
450            "HARN_RELEASE_EFFORT",
451        ] {
452            assert!(variable_spec(name).is_some(), "{name}");
453        }
454        for name in [
455            "HARN_AGENT_REVIEW_UNKNOWN",
456            "HARN_LLM_TIMOUT",
457            "HARN_RELEASE_",
458        ] {
459            assert!(variable_spec(name).is_none(), "{name}");
460        }
461    }
462
463    #[test]
464    fn credential_metadata_covers_non_api_oauth_keys() {
465        assert_eq!(
466            variable_spec("HARN_OAUTH_KEY").unwrap().sensitivity,
467            EnvironmentSensitivity::Credential
468        );
469    }
470
471    #[test]
472    fn invalid_known_value_is_rejected_at_startup() {
473        let error = validate_environment([("HARN_LLM_TIMEOUT", "soon")]).unwrap_err();
474        assert_eq!(
475            error.diagnostics()[0].kind,
476            EnvironmentDiagnosticKind::InvalidValue {
477                expected: EnvironmentValueShape::UnsignedInteger
478            }
479        );
480    }
481
482    #[test]
483    fn diagnostics_cannot_render_values_even_for_credentials() {
484        let secret = "must-never-appear";
485        let error = validate_environment([("HARN_CLOUD_API_KEZ", secret)]).unwrap_err();
486        let rendered = error.to_string();
487        assert!(rendered.contains("HARN_CLOUD_API_KEZ"));
488        assert!(!rendered.contains(secret));
489    }
490
491    #[test]
492    fn registry_is_sorted_unique_and_contains_metadata() {
493        let names = registered_names();
494        assert!(
495            names.windows(2).all(|pair| pair[0] < pair[1]),
496            "environment registry must remain sorted and unique"
497        );
498        let timeout = variable_spec("HARN_LLM_TIMEOUT").unwrap();
499        assert_eq!(timeout.consumer, EnvironmentConsumer::Runtime);
500        assert_eq!(timeout.value_shape, EnvironmentValueShape::UnsignedInteger);
501        let token = variable_spec("HARN_PACKAGE_REGISTRY_TOKEN").unwrap();
502        assert_eq!(token.sensitivity, EnvironmentSensitivity::Credential);
503    }
504
505    #[test]
506    fn embedded_runtime_bootstrap_accepts_registered_process_environment() {
507        crate::initialize_runtime().expect("registered process environment");
508    }
509
510    #[test]
511    fn every_compiled_harn_name_is_registered_or_structurally_owned() {
512        let crates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
513            .parent()
514            .expect("harn-vm lives below crates");
515        let mut missing = std::collections::BTreeSet::new();
516        for entry in walkdir::WalkDir::new(crates_dir)
517            .into_iter()
518            .filter_map(Result::ok)
519            .filter(|entry| {
520                entry.file_type().is_file()
521                    && entry.path().extension().and_then(OsStr::to_str) == Some("rs")
522                    && entry
523                        .path()
524                        .components()
525                        .any(|component| component.as_os_str() == "src")
526                    && entry.file_name() != "environment_registry.rs"
527            })
528        {
529            let source = std::fs::read_to_string(entry.path()).expect("read Rust source");
530            for token in harn_name_tokens(&source) {
531                if variable_spec(token).is_none()
532                    && !matches!(token, "HARN_LLM_" | "HARN_LLM_ROLE_" | "HARN_SECRET_")
533                {
534                    missing.insert(format!("{}: {token}", entry.path().display()));
535                }
536            }
537        }
538        assert!(
539            missing.is_empty(),
540            "compiled HARN_* names missing from environment_registry_names.txt:\n{}",
541            missing.into_iter().collect::<Vec<_>>().join("\n")
542        );
543    }
544
545    #[test]
546    fn every_harn_script_owned_name_is_registered_or_structurally_owned() {
547        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
548            .ancestors()
549            .nth(2)
550            .expect("harn-vm lives below workspace/crates");
551        let source_roots = [
552            "benchmarks",
553            "conformance",
554            "crates",
555            "evals",
556            "examples",
557            "experiments",
558            "perf",
559            "personas",
560            "scripts",
561            "tests",
562        ];
563        let mut missing = std::collections::BTreeSet::new();
564        for source_root in source_roots {
565            let source_root = workspace_root.join(source_root);
566            if !source_root.exists() {
567                continue;
568            }
569            for entry in walkdir::WalkDir::new(source_root)
570                .into_iter()
571                .filter_map(Result::ok)
572                .filter(|entry| {
573                    entry.file_type().is_file()
574                        && entry.path().extension().and_then(OsStr::to_str) == Some("harn")
575                })
576            {
577                let source = std::fs::read_to_string(entry.path()).expect("read Harn source");
578                for token in harn_name_tokens(&source) {
579                    if variable_spec(token).is_none()
580                        && !matches!(
581                            token,
582                            "HARN_AGENT"
583                                | "HARN_AGENT_"
584                                | "HARN_LLM"
585                                | "HARN_LLM_"
586                                | "HARN_PLANNER"
587                                | "HARN_RELEASE"
588                        )
589                    {
590                        missing.insert(format!("{}: {token}", entry.path().display()));
591                    }
592                }
593            }
594        }
595        assert!(
596            missing.is_empty(),
597            "Harn-script HARN_* names missing from environment_registry_names.txt:\n{}",
598            missing.into_iter().collect::<Vec<_>>().join("\n")
599        );
600    }
601
602    fn harn_name_tokens(source: &str) -> impl Iterator<Item = &str> {
603        source.match_indices("\"HARN_").filter_map(|(quote, _)| {
604            let start = quote + 1;
605            let bytes = source.as_bytes();
606            let mut end = start + "HARN_".len();
607            while end < bytes.len()
608                && (bytes[end].is_ascii_uppercase()
609                    || bytes[end].is_ascii_digit()
610                    || bytes[end] == b'_')
611            {
612                end += 1;
613            }
614            (end > start + "HARN_".len()).then(|| &source[start..end])
615        })
616    }
617}