Skip to main content

car_context/
lib.rs

1//! Context shared by persisted agent definitions and credential routing.
2//! This identifies work ownership; it is not proof of organization membership.
3//!
4//! [`resolve`] decides which context a new session binds: the user's default
5//! org, a repository's org, an agent's permitted set and an explicit choice
6//! combine under one rule that refuses rather than falls back.
7use serde::{Deserialize, Serialize};
8
9pub mod resolve;
10
11/// Check an organization id's canonical form: 1-256 bytes of ASCII
12/// alphanumerics, `.`, `_` or `-`. Shared by [`WorkContext::validate`] and by
13/// places that name an org without an account, such as a repository's
14/// `.car/config.toml`.
15pub fn validate_organization_id(org: &str) -> Result<(), String> {
16    if org.is_empty()
17        || org.len() > 256
18        || !org
19            .bytes()
20            .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
21    {
22        return Err(format!(
23            "`{org}` is not a canonical organization id (letters, digits, `.`, `_`, `-`)"
24        ));
25    }
26    Ok(())
27}
28
29/// Immutable authority on an agent/resource. This is a routing key, not a
30/// membership attestation. The token issuer validates each requested org.
31#[derive(
32    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
33)]
34#[serde(deny_unknown_fields)]
35pub struct WorkContext {
36    pub api_base: String,
37    pub account_id: String,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub organization_id: Option<String>,
40}
41
42impl WorkContext {
43    pub fn new(
44        api_base: impl Into<String>,
45        account_id: impl Into<String>,
46        organization_id: Option<String>,
47    ) -> Result<Self, String> {
48        let context = Self {
49            api_base: api_base.into().trim_end_matches('/').to_string(),
50            account_id: account_id.into(),
51            organization_id,
52        };
53        context.validate()?;
54        Ok(context)
55    }
56
57    /// Stable filename-safe private resource key. Length framing prevents
58    /// separator aliases; authority, account and organization all participate.
59    pub fn storage_key(&self, resource: &str) -> String {
60        use sha2::{Digest, Sha256};
61        let mut hash = Sha256::new();
62        hash.update(b"car-work-context-v1");
63        for value in [
64            self.api_base.as_str(),
65            self.account_id.as_str(),
66            self.organization_id.as_deref().unwrap_or(""),
67            resource,
68        ] {
69            hash.update((value.len() as u64).to_be_bytes());
70            hash.update(value.as_bytes());
71        }
72        format!("work-{:x}", hash.finalize())
73    }
74
75    pub fn validate(&self) -> Result<(), String> {
76        let url = url::Url::parse(&self.api_base).map_err(|_| "invalid credential authority")?;
77        let loopback = url.host_str().is_some_and(|host| {
78            host == "localhost"
79                || host
80                    .parse::<std::net::IpAddr>()
81                    .is_ok_and(|ip| ip.is_loopback())
82        });
83        if (url.scheme() != "https" && !(url.scheme() == "http" && loopback))
84            || !url.username().is_empty()
85            || url.password().is_some()
86            || url.query().is_some()
87            || url.fragment().is_some()
88            || self.api_base.ends_with('/')
89            || self.account_id.trim() != self.account_id
90            || self.account_id.is_empty()
91            || self.account_id.len() > 256
92        {
93            return Err("credential context requires a canonical authority and account".into());
94        }
95        if self
96            .organization_id
97            .as_deref()
98            .is_some_and(|org| validate_organization_id(org).is_err())
99        {
100            return Err("credential context requires a canonical organization id".into());
101        }
102        Ok(())
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    #[test]
110    fn private_resource_keys_keep_all_ownership_dimensions_separate() {
111        let base =
112            WorkContext::new("https://api.parslee.ai", "matt", Some("parslee".into())).unwrap();
113        let key = base.storage_key("agent:same-id");
114        let mut contexts = Vec::new();
115        let mut other = base.clone();
116        other.organization_id = Some("flyexclusive".into());
117        contexts.push(other);
118        let mut other = base.clone();
119        other.organization_id = None;
120        contexts.push(other);
121        let mut other = base.clone();
122        other.account_id = "colleague".into();
123        contexts.push(other);
124        let mut other = base.clone();
125        other.api_base = "https://other.example".into();
126        contexts.push(other);
127        for other in contexts {
128            assert_ne!(other.storage_key("agent:same-id"), key);
129        }
130        assert_ne!(base.storage_key("agent:other-id"), key);
131        assert_eq!(
132            serde_json::from_str::<WorkContext>(&serde_json::to_string(&base).unwrap())
133                .unwrap()
134                .storage_key("agent:same-id"),
135            key
136        );
137    }
138}