Skip to main content

harn_hostlib/
host_env_custody.rs

1//! Shared host-environment custody contracts.
2//!
3//! Harn embedders often need a script or remote runtime to name a host
4//! environment class without ever seeing the values in that class. The common
5//! pattern is a named environment palette: the orchestrator sends class names
6//! over the wire, the host resolves values locally, and receipts/audit trails
7//! keep only class names.
8
9use std::collections::BTreeSet;
10use std::fmt;
11
12use serde::{Deserialize, Serialize};
13use serde_json::{Map, Value};
14
15/// Canonical custody mode for a host-held named environment palette.
16pub const HOST_ENV_CUSTODY_MODE_NAMED_ENV_PALETTE: &str = "named_env_palette";
17/// Wire policy stating that only env class names may cross a protocol boundary.
18pub const HOST_ENV_CUSTODY_WIRE_CLASS_NAMES_ONLY: &str = "class_names_only";
19/// Host policy stating that credential values are resolved only in the host.
20pub const HOST_ENV_CUSTODY_HOST_VALUES_ONLY: &str = "host_values_only";
21/// Sandbox policy stating that secret values are never sandbox-visible.
22pub const HOST_ENV_CUSTODY_SANDBOX_NO_SECRET_VALUES: &str = "no_secret_values";
23/// Metadata object key carrying a [`HostEnvCustodyContract`].
24pub const HOST_ENV_CUSTODY_METADATA_KEY: &str = "host_env_custody";
25
26/// Serializable contract for a host-resolved named environment palette.
27///
28/// The contract intentionally carries class names only. It is suitable for
29/// protocol metadata, receipts, audit logs, and checkpoint records. It is not a
30/// secret container and rejects unknown JSON fields so callers cannot smuggle
31/// secret-looking side fields into a validated custody object.
32#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
33#[serde(default, deny_unknown_fields)]
34pub struct HostEnvCustodyContract {
35    /// Custody mode. Currently only `named_env_palette` is accepted.
36    pub mode: String,
37    /// Env classes whose values are issued by the orchestrator but injected
38    /// only into the host process, never into the VM/script payload.
39    pub orchestrator_issued_host_env_classes: Vec<String>,
40    /// Env classes whose values are held and resolved by the host itself.
41    pub host_held_env_classes: Vec<String>,
42    /// Env classes the host may expose to a sandboxed command. These should be
43    /// credential-free classes such as `agent` or `verify`.
44    pub sandbox_visible_env_classes: Vec<String>,
45    /// Wire policy. Currently only `class_names_only` is accepted.
46    pub wire_value_policy: String,
47    /// Host value policy. Currently only `host_values_only` is accepted.
48    pub host_value_policy: String,
49    /// Sandbox value policy. Currently only `no_secret_values` is accepted.
50    pub sandbox_value_policy: String,
51}
52
53impl Default for HostEnvCustodyContract {
54    fn default() -> Self {
55        Self {
56            mode: HOST_ENV_CUSTODY_MODE_NAMED_ENV_PALETTE.to_string(),
57            orchestrator_issued_host_env_classes: Vec::new(),
58            host_held_env_classes: Vec::new(),
59            sandbox_visible_env_classes: Vec::new(),
60            wire_value_policy: HOST_ENV_CUSTODY_WIRE_CLASS_NAMES_ONLY.to_string(),
61            host_value_policy: HOST_ENV_CUSTODY_HOST_VALUES_ONLY.to_string(),
62            sandbox_value_policy: HOST_ENV_CUSTODY_SANDBOX_NO_SECRET_VALUES.to_string(),
63        }
64    }
65}
66
67impl HostEnvCustodyContract {
68    /// Trim, sort, deduplicate, and validate class-name lists.
69    pub fn normalized(mut self) -> Result<Self, HostEnvCustodyError> {
70        validate_literal(
71            "host_env_custody.mode",
72            &self.mode,
73            HOST_ENV_CUSTODY_MODE_NAMED_ENV_PALETTE,
74        )?;
75        validate_literal(
76            "host_env_custody.wire_value_policy",
77            &self.wire_value_policy,
78            HOST_ENV_CUSTODY_WIRE_CLASS_NAMES_ONLY,
79        )?;
80        validate_literal(
81            "host_env_custody.host_value_policy",
82            &self.host_value_policy,
83            HOST_ENV_CUSTODY_HOST_VALUES_ONLY,
84        )?;
85        validate_literal(
86            "host_env_custody.sandbox_value_policy",
87            &self.sandbox_value_policy,
88            HOST_ENV_CUSTODY_SANDBOX_NO_SECRET_VALUES,
89        )?;
90        self.orchestrator_issued_host_env_classes = normalize_env_classes(
91            "host_env_custody.orchestrator_issued_host_env_classes",
92            self.orchestrator_issued_host_env_classes,
93        )?;
94        self.host_held_env_classes = normalize_env_classes(
95            "host_env_custody.host_held_env_classes",
96            self.host_held_env_classes,
97        )?;
98        self.sandbox_visible_env_classes = normalize_env_classes(
99            "host_env_custody.sandbox_visible_env_classes",
100            self.sandbox_visible_env_classes,
101        )?;
102        Ok(self)
103    }
104}
105
106/// Return a metadata object containing one normalized host-env custody contract.
107pub fn host_env_custody_metadata(
108    contract: HostEnvCustodyContract,
109) -> Result<Value, HostEnvCustodyError> {
110    let mut metadata = Map::new();
111    metadata.insert(
112        HOST_ENV_CUSTODY_METADATA_KEY.to_string(),
113        host_env_custody_value(contract)?,
114    );
115    Ok(Value::Object(metadata))
116}
117
118/// Normalize a JSON metadata object containing optional host-env custody data.
119///
120/// The input must be a JSON object. If the `host_env_custody` key is absent,
121/// the object is returned unchanged. If present, the value must deserialize to a
122/// [`HostEnvCustodyContract`] and is replaced with its canonical normalized
123/// form.
124pub fn normalize_host_env_custody_metadata(
125    mut metadata: Value,
126) -> Result<Value, HostEnvCustodyError> {
127    let Value::Object(object) = &mut metadata else {
128        return Err(HostEnvCustodyError::new(
129            "host_env_custody metadata envelope must be a JSON object",
130        ));
131    };
132    normalize_host_env_custody_metadata_object(object)?;
133    Ok(metadata)
134}
135
136/// Normalize optional host-env custody data inside an existing metadata object.
137pub fn normalize_host_env_custody_metadata_object(
138    metadata: &mut Map<String, Value>,
139) -> Result<(), HostEnvCustodyError> {
140    let Some(value) = metadata.get(HOST_ENV_CUSTODY_METADATA_KEY) else {
141        return Ok(());
142    };
143    let custody: HostEnvCustodyContract =
144        serde_json::from_value(value.clone()).map_err(|error| {
145            HostEnvCustodyError::new(format!(
146                "metadata.host_env_custody must be a host env custody contract: {error}"
147            ))
148        })?;
149    metadata.insert(
150        HOST_ENV_CUSTODY_METADATA_KEY.to_string(),
151        host_env_custody_value(custody)?,
152    );
153    Ok(())
154}
155
156/// Error returned when a custody contract is not class-name-only metadata.
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub struct HostEnvCustodyError {
159    message: String,
160}
161
162impl HostEnvCustodyError {
163    fn new(message: impl Into<String>) -> Self {
164        Self {
165            message: message.into(),
166        }
167    }
168
169    /// Human-readable validation failure.
170    pub fn message(&self) -> &str {
171        &self.message
172    }
173}
174
175impl fmt::Display for HostEnvCustodyError {
176    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177        formatter.write_str(&self.message)
178    }
179}
180
181impl std::error::Error for HostEnvCustodyError {}
182
183fn validate_literal(
184    field: &str,
185    value: &str,
186    expected: &'static str,
187) -> Result<(), HostEnvCustodyError> {
188    if value == expected {
189        return Ok(());
190    }
191    Err(HostEnvCustodyError::new(format!(
192        "{field} must be `{expected}`"
193    )))
194}
195
196fn host_env_custody_value(contract: HostEnvCustodyContract) -> Result<Value, HostEnvCustodyError> {
197    let custody = contract.normalized().map_err(|error| {
198        HostEnvCustodyError::new(format!("metadata.host_env_custody invalid: {error}"))
199    })?;
200    serde_json::to_value(custody).map_err(|error| {
201        HostEnvCustodyError::new(format!(
202            "metadata.host_env_custody could not be serialized: {error}"
203        ))
204    })
205}
206
207fn normalize_env_classes(
208    field: &str,
209    values: Vec<String>,
210) -> Result<Vec<String>, HostEnvCustodyError> {
211    let mut normalized = BTreeSet::new();
212    for value in values {
213        let value = value.trim();
214        if value.is_empty() {
215            continue;
216        }
217        validate_env_class_name(field, value)?;
218        normalized.insert(value.to_string());
219    }
220    Ok(normalized.into_iter().collect())
221}
222
223fn validate_env_class_name(field: &str, value: &str) -> Result<(), HostEnvCustodyError> {
224    if value.len() > 96 {
225        return Err(HostEnvCustodyError::new(format!(
226            "{field} entries must be at most 96 bytes"
227        )));
228    }
229    if !value.bytes().all(|byte| {
230        byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b':' | b'_' | b'-' | b'/')
231    }) {
232        return Err(HostEnvCustodyError::new(format!(
233            "{field} entries must be env_class names, not key/value payloads"
234        )));
235    }
236    if looks_like_secret_value(value) {
237        return Err(HostEnvCustodyError::new(format!(
238            "{field} entries must be env_class names, not credential values"
239        )));
240    }
241    Ok(())
242}
243
244fn looks_like_secret_value(value: &str) -> bool {
245    let lower = value.to_ascii_lowercase();
246    lower.starts_with("github_pat_")
247        || lower.starts_with("ghp_")
248        || lower.starts_with("gho_")
249        || lower.starts_with("ghu_")
250        || lower.starts_with("ghs_")
251        || lower.starts_with("ghr_")
252        || lower.starts_with("glpat-")
253        || lower.starts_with("xoxb-")
254        || lower.starts_with("xoxp-")
255        || lower.starts_with("xapp-")
256        || lower.starts_with("sk-")
257        || value.starts_with("-----BEGIN")
258}
259
260#[cfg(test)]
261mod tests {
262    use serde_json::json;
263
264    use super::*;
265
266    #[test]
267    fn host_env_custody_normalizes_class_names() {
268        let contract = HostEnvCustodyContract {
269            orchestrator_issued_host_env_classes: vec![
270                " github.scoped ".to_string(),
271                "model.provider".to_string(),
272                "github.scoped".to_string(),
273            ],
274            host_held_env_classes: vec!["customer.registry".to_string()],
275            sandbox_visible_env_classes: vec!["verify".to_string(), "agent".to_string()],
276            ..HostEnvCustodyContract::default()
277        }
278        .normalized()
279        .expect("contract");
280
281        assert_eq!(
282            contract.orchestrator_issued_host_env_classes,
283            vec!["github.scoped".to_string(), "model.provider".to_string()]
284        );
285        assert_eq!(
286            contract.sandbox_visible_env_classes,
287            vec!["agent".to_string(), "verify".to_string()]
288        );
289        assert_eq!(
290            serde_json::to_value(&contract).expect("json"),
291            json!({
292                "mode": "named_env_palette",
293                "orchestrator_issued_host_env_classes": ["github.scoped", "model.provider"],
294                "host_held_env_classes": ["customer.registry"],
295                "sandbox_visible_env_classes": ["agent", "verify"],
296                "wire_value_policy": "class_names_only",
297                "host_value_policy": "host_values_only",
298                "sandbox_value_policy": "no_secret_values"
299            })
300        );
301    }
302
303    #[test]
304    fn host_env_custody_rejects_secret_like_values() {
305        let error =
306            HostEnvCustodyContract {
307                orchestrator_issued_host_env_classes: vec![
308                    "ghp_abcdefghijklmnopqrstuvwxyz".to_string()
309                ],
310                ..HostEnvCustodyContract::default()
311            }
312            .normalized()
313            .expect_err("credential-looking values are rejected");
314
315        assert_eq!(
316            error.message(),
317            "host_env_custody.orchestrator_issued_host_env_classes entries must be env_class names, not credential values"
318        );
319    }
320
321    #[test]
322    fn host_env_custody_rejects_key_value_payloads() {
323        let error = HostEnvCustodyContract {
324            host_held_env_classes: vec!["OPENAI_API_KEY=sk-test".to_string()],
325            ..HostEnvCustodyContract::default()
326        }
327        .normalized()
328        .expect_err("env assignments are rejected");
329
330        assert_eq!(
331            error.message(),
332            "host_env_custody.host_held_env_classes entries must be env_class names, not key/value payloads"
333        );
334    }
335
336    #[test]
337    fn host_env_custody_rejects_unknown_json_fields() {
338        let error = serde_json::from_value::<HostEnvCustodyContract>(json!({
339            "mode": "named_env_palette",
340            "token": "ghp_should_not_parse"
341        }))
342        .expect_err("unknown fields rejected");
343
344        assert!(
345            error.to_string().contains("unknown field `token`"),
346            "unexpected error: {error}"
347        );
348    }
349
350    #[test]
351    fn host_env_custody_metadata_normalizes_optional_contract() {
352        let metadata = normalize_host_env_custody_metadata(json!({
353            "claim_kind": "worker_interactive",
354            "host_env_custody": {
355                "orchestrator_issued_host_env_classes": [
356                    " model.provider ",
357                    "github.scoped",
358                    "github.scoped"
359                ],
360                "host_held_env_classes": [" customer.registry "],
361                "sandbox_visible_env_classes": ["verify", "agent"]
362            }
363        }))
364        .expect("metadata");
365
366        assert_eq!(metadata["claim_kind"], json!("worker_interactive"));
367        assert_eq!(
368            metadata["host_env_custody"],
369            json!({
370                "mode": "named_env_palette",
371                "orchestrator_issued_host_env_classes": ["github.scoped", "model.provider"],
372                "host_held_env_classes": ["customer.registry"],
373                "sandbox_visible_env_classes": ["agent", "verify"],
374                "wire_value_policy": "class_names_only",
375                "host_value_policy": "host_values_only",
376                "sandbox_value_policy": "no_secret_values"
377            })
378        );
379    }
380
381    #[test]
382    fn host_env_custody_metadata_without_contract_is_noop() {
383        let metadata = json!({"claim_kind": "worker_interactive"});
384
385        assert_eq!(
386            normalize_host_env_custody_metadata(metadata.clone()).expect("metadata"),
387            metadata
388        );
389    }
390
391    #[test]
392    fn host_env_custody_metadata_rejects_non_object_envelope() {
393        let error =
394            normalize_host_env_custody_metadata(json!(["not", "metadata"])).expect_err("object");
395
396        assert_eq!(
397            error.message(),
398            "host_env_custody metadata envelope must be a JSON object"
399        );
400    }
401
402    #[test]
403    fn host_env_custody_metadata_rejects_invalid_contract() {
404        let error = normalize_host_env_custody_metadata(json!({
405            "host_env_custody": {
406                "orchestrator_issued_host_env_classes": ["sk-test"]
407            }
408        }))
409        .expect_err("credential value rejected");
410
411        assert_eq!(
412            error.message(),
413            "metadata.host_env_custody invalid: host_env_custody.orchestrator_issued_host_env_classes entries must be env_class names, not credential values"
414        );
415    }
416
417    #[test]
418    fn host_env_custody_metadata_constructor_normalizes_contract() {
419        let metadata = host_env_custody_metadata(HostEnvCustodyContract {
420            host_held_env_classes: vec![" customer.registry ".to_string(), "agent".to_string()],
421            ..HostEnvCustodyContract::default()
422        })
423        .expect("metadata");
424
425        assert_eq!(
426            metadata,
427            json!({
428                "host_env_custody": {
429                    "mode": "named_env_palette",
430                    "orchestrator_issued_host_env_classes": [],
431                    "host_held_env_classes": ["agent", "customer.registry"],
432                    "sandbox_visible_env_classes": [],
433                    "wire_value_policy": "class_names_only",
434                    "host_value_policy": "host_values_only",
435                    "sandbox_value_policy": "no_secret_values"
436                }
437            })
438        );
439    }
440}