1use std::collections::BTreeSet;
10use std::fmt;
11
12use serde::{Deserialize, Serialize};
13use serde_json::{Map, Value};
14
15pub const HOST_ENV_CUSTODY_MODE_NAMED_ENV_PALETTE: &str = "named_env_palette";
17pub const HOST_ENV_CUSTODY_WIRE_CLASS_NAMES_ONLY: &str = "class_names_only";
19pub const HOST_ENV_CUSTODY_HOST_VALUES_ONLY: &str = "host_values_only";
21pub const HOST_ENV_CUSTODY_SANDBOX_NO_SECRET_VALUES: &str = "no_secret_values";
23pub const HOST_ENV_CUSTODY_METADATA_KEY: &str = "host_env_custody";
25
26#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
33#[serde(default, deny_unknown_fields)]
34pub struct HostEnvCustodyContract {
35 pub mode: String,
37 pub orchestrator_issued_host_env_classes: Vec<String>,
40 pub host_held_env_classes: Vec<String>,
42 pub sandbox_visible_env_classes: Vec<String>,
45 pub wire_value_policy: String,
47 pub host_value_policy: String,
49 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 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
106pub 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
118pub 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
136pub 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#[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 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}