use std::collections::BTreeSet;
use std::fmt;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub const HOST_ENV_CUSTODY_MODE_NAMED_ENV_PALETTE: &str = "named_env_palette";
pub const HOST_ENV_CUSTODY_WIRE_CLASS_NAMES_ONLY: &str = "class_names_only";
pub const HOST_ENV_CUSTODY_HOST_VALUES_ONLY: &str = "host_values_only";
pub const HOST_ENV_CUSTODY_SANDBOX_NO_SECRET_VALUES: &str = "no_secret_values";
pub const HOST_ENV_CUSTODY_METADATA_KEY: &str = "host_env_custody";
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HostEnvCustodyContract {
pub mode: String,
pub orchestrator_issued_host_env_classes: Vec<String>,
pub host_held_env_classes: Vec<String>,
pub sandbox_visible_env_classes: Vec<String>,
pub wire_value_policy: String,
pub host_value_policy: String,
pub sandbox_value_policy: String,
}
impl Default for HostEnvCustodyContract {
fn default() -> Self {
Self {
mode: HOST_ENV_CUSTODY_MODE_NAMED_ENV_PALETTE.to_string(),
orchestrator_issued_host_env_classes: Vec::new(),
host_held_env_classes: Vec::new(),
sandbox_visible_env_classes: Vec::new(),
wire_value_policy: HOST_ENV_CUSTODY_WIRE_CLASS_NAMES_ONLY.to_string(),
host_value_policy: HOST_ENV_CUSTODY_HOST_VALUES_ONLY.to_string(),
sandbox_value_policy: HOST_ENV_CUSTODY_SANDBOX_NO_SECRET_VALUES.to_string(),
}
}
}
impl HostEnvCustodyContract {
pub fn normalized(mut self) -> Result<Self, HostEnvCustodyError> {
validate_literal(
"host_env_custody.mode",
&self.mode,
HOST_ENV_CUSTODY_MODE_NAMED_ENV_PALETTE,
)?;
validate_literal(
"host_env_custody.wire_value_policy",
&self.wire_value_policy,
HOST_ENV_CUSTODY_WIRE_CLASS_NAMES_ONLY,
)?;
validate_literal(
"host_env_custody.host_value_policy",
&self.host_value_policy,
HOST_ENV_CUSTODY_HOST_VALUES_ONLY,
)?;
validate_literal(
"host_env_custody.sandbox_value_policy",
&self.sandbox_value_policy,
HOST_ENV_CUSTODY_SANDBOX_NO_SECRET_VALUES,
)?;
self.orchestrator_issued_host_env_classes = normalize_env_classes(
"host_env_custody.orchestrator_issued_host_env_classes",
self.orchestrator_issued_host_env_classes,
)?;
self.host_held_env_classes = normalize_env_classes(
"host_env_custody.host_held_env_classes",
self.host_held_env_classes,
)?;
self.sandbox_visible_env_classes = normalize_env_classes(
"host_env_custody.sandbox_visible_env_classes",
self.sandbox_visible_env_classes,
)?;
Ok(self)
}
}
pub fn host_env_custody_metadata(
contract: HostEnvCustodyContract,
) -> Result<Value, HostEnvCustodyError> {
let mut metadata = Map::new();
metadata.insert(
HOST_ENV_CUSTODY_METADATA_KEY.to_string(),
host_env_custody_value(contract)?,
);
Ok(Value::Object(metadata))
}
pub fn normalize_host_env_custody_metadata(
mut metadata: Value,
) -> Result<Value, HostEnvCustodyError> {
let Value::Object(object) = &mut metadata else {
return Err(HostEnvCustodyError::new(
"host_env_custody metadata envelope must be a JSON object",
));
};
normalize_host_env_custody_metadata_object(object)?;
Ok(metadata)
}
pub fn normalize_host_env_custody_metadata_object(
metadata: &mut Map<String, Value>,
) -> Result<(), HostEnvCustodyError> {
let Some(value) = metadata.get(HOST_ENV_CUSTODY_METADATA_KEY) else {
return Ok(());
};
let custody: HostEnvCustodyContract =
serde_json::from_value(value.clone()).map_err(|error| {
HostEnvCustodyError::new(format!(
"metadata.host_env_custody must be a host env custody contract: {error}"
))
})?;
metadata.insert(
HOST_ENV_CUSTODY_METADATA_KEY.to_string(),
host_env_custody_value(custody)?,
);
Ok(())
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HostEnvCustodyError {
message: String,
}
impl HostEnvCustodyError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn message(&self) -> &str {
&self.message
}
}
impl fmt::Display for HostEnvCustodyError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for HostEnvCustodyError {}
fn validate_literal(
field: &str,
value: &str,
expected: &'static str,
) -> Result<(), HostEnvCustodyError> {
if value == expected {
return Ok(());
}
Err(HostEnvCustodyError::new(format!(
"{field} must be `{expected}`"
)))
}
fn host_env_custody_value(contract: HostEnvCustodyContract) -> Result<Value, HostEnvCustodyError> {
let custody = contract.normalized().map_err(|error| {
HostEnvCustodyError::new(format!("metadata.host_env_custody invalid: {error}"))
})?;
serde_json::to_value(custody).map_err(|error| {
HostEnvCustodyError::new(format!(
"metadata.host_env_custody could not be serialized: {error}"
))
})
}
fn normalize_env_classes(
field: &str,
values: Vec<String>,
) -> Result<Vec<String>, HostEnvCustodyError> {
let mut normalized = BTreeSet::new();
for value in values {
let value = value.trim();
if value.is_empty() {
continue;
}
validate_env_class_name(field, value)?;
normalized.insert(value.to_string());
}
Ok(normalized.into_iter().collect())
}
fn validate_env_class_name(field: &str, value: &str) -> Result<(), HostEnvCustodyError> {
if value.len() > 96 {
return Err(HostEnvCustodyError::new(format!(
"{field} entries must be at most 96 bytes"
)));
}
if !value.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b':' | b'_' | b'-' | b'/')
}) {
return Err(HostEnvCustodyError::new(format!(
"{field} entries must be env_class names, not key/value payloads"
)));
}
if looks_like_secret_value(value) {
return Err(HostEnvCustodyError::new(format!(
"{field} entries must be env_class names, not credential values"
)));
}
Ok(())
}
fn looks_like_secret_value(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
lower.starts_with("github_pat_")
|| lower.starts_with("ghp_")
|| lower.starts_with("gho_")
|| lower.starts_with("ghu_")
|| lower.starts_with("ghs_")
|| lower.starts_with("ghr_")
|| lower.starts_with("glpat-")
|| lower.starts_with("xoxb-")
|| lower.starts_with("xoxp-")
|| lower.starts_with("xapp-")
|| lower.starts_with("sk-")
|| value.starts_with("-----BEGIN")
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn host_env_custody_normalizes_class_names() {
let contract = HostEnvCustodyContract {
orchestrator_issued_host_env_classes: vec![
" github.scoped ".to_string(),
"model.provider".to_string(),
"github.scoped".to_string(),
],
host_held_env_classes: vec!["customer.registry".to_string()],
sandbox_visible_env_classes: vec!["verify".to_string(), "agent".to_string()],
..HostEnvCustodyContract::default()
}
.normalized()
.expect("contract");
assert_eq!(
contract.orchestrator_issued_host_env_classes,
vec!["github.scoped".to_string(), "model.provider".to_string()]
);
assert_eq!(
contract.sandbox_visible_env_classes,
vec!["agent".to_string(), "verify".to_string()]
);
assert_eq!(
serde_json::to_value(&contract).expect("json"),
json!({
"mode": "named_env_palette",
"orchestrator_issued_host_env_classes": ["github.scoped", "model.provider"],
"host_held_env_classes": ["customer.registry"],
"sandbox_visible_env_classes": ["agent", "verify"],
"wire_value_policy": "class_names_only",
"host_value_policy": "host_values_only",
"sandbox_value_policy": "no_secret_values"
})
);
}
#[test]
fn host_env_custody_rejects_secret_like_values() {
let error =
HostEnvCustodyContract {
orchestrator_issued_host_env_classes: vec![
"ghp_abcdefghijklmnopqrstuvwxyz".to_string()
],
..HostEnvCustodyContract::default()
}
.normalized()
.expect_err("credential-looking values are rejected");
assert_eq!(
error.message(),
"host_env_custody.orchestrator_issued_host_env_classes entries must be env_class names, not credential values"
);
}
#[test]
fn host_env_custody_rejects_key_value_payloads() {
let error = HostEnvCustodyContract {
host_held_env_classes: vec!["OPENAI_API_KEY=sk-test".to_string()],
..HostEnvCustodyContract::default()
}
.normalized()
.expect_err("env assignments are rejected");
assert_eq!(
error.message(),
"host_env_custody.host_held_env_classes entries must be env_class names, not key/value payloads"
);
}
#[test]
fn host_env_custody_rejects_unknown_json_fields() {
let error = serde_json::from_value::<HostEnvCustodyContract>(json!({
"mode": "named_env_palette",
"token": "ghp_should_not_parse"
}))
.expect_err("unknown fields rejected");
assert!(
error.to_string().contains("unknown field `token`"),
"unexpected error: {error}"
);
}
#[test]
fn host_env_custody_metadata_normalizes_optional_contract() {
let metadata = normalize_host_env_custody_metadata(json!({
"claim_kind": "worker_interactive",
"host_env_custody": {
"orchestrator_issued_host_env_classes": [
" model.provider ",
"github.scoped",
"github.scoped"
],
"host_held_env_classes": [" customer.registry "],
"sandbox_visible_env_classes": ["verify", "agent"]
}
}))
.expect("metadata");
assert_eq!(metadata["claim_kind"], json!("worker_interactive"));
assert_eq!(
metadata["host_env_custody"],
json!({
"mode": "named_env_palette",
"orchestrator_issued_host_env_classes": ["github.scoped", "model.provider"],
"host_held_env_classes": ["customer.registry"],
"sandbox_visible_env_classes": ["agent", "verify"],
"wire_value_policy": "class_names_only",
"host_value_policy": "host_values_only",
"sandbox_value_policy": "no_secret_values"
})
);
}
#[test]
fn host_env_custody_metadata_without_contract_is_noop() {
let metadata = json!({"claim_kind": "worker_interactive"});
assert_eq!(
normalize_host_env_custody_metadata(metadata.clone()).expect("metadata"),
metadata
);
}
#[test]
fn host_env_custody_metadata_rejects_non_object_envelope() {
let error =
normalize_host_env_custody_metadata(json!(["not", "metadata"])).expect_err("object");
assert_eq!(
error.message(),
"host_env_custody metadata envelope must be a JSON object"
);
}
#[test]
fn host_env_custody_metadata_rejects_invalid_contract() {
let error = normalize_host_env_custody_metadata(json!({
"host_env_custody": {
"orchestrator_issued_host_env_classes": ["sk-test"]
}
}))
.expect_err("credential value rejected");
assert_eq!(
error.message(),
"metadata.host_env_custody invalid: host_env_custody.orchestrator_issued_host_env_classes entries must be env_class names, not credential values"
);
}
#[test]
fn host_env_custody_metadata_constructor_normalizes_contract() {
let metadata = host_env_custody_metadata(HostEnvCustodyContract {
host_held_env_classes: vec![" customer.registry ".to_string(), "agent".to_string()],
..HostEnvCustodyContract::default()
})
.expect("metadata");
assert_eq!(
metadata,
json!({
"host_env_custody": {
"mode": "named_env_palette",
"orchestrator_issued_host_env_classes": [],
"host_held_env_classes": ["agent", "customer.registry"],
"sandbox_visible_env_classes": [],
"wire_value_policy": "class_names_only",
"host_value_policy": "host_values_only",
"sandbox_value_policy": "no_secret_values"
}
})
);
}
}