Skip to main content

a3s_code_core/capability/
run_binding.rs

1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3use thiserror::Error;
4
5use super::{
6    CapabilityCeiling, CapabilitySet, Sha256Digest, UseCapabilityGeneration,
7    CAPABILITY_CEILING_SCHEMA, CAPABILITY_SET_SCHEMA, MAX_CAPABILITY_CANONICAL_BYTES,
8    USE_CAPABILITY_SNAPSHOT_CURSOR_SCHEMA,
9};
10
11pub const RUN_CAPABILITY_BINDING_SCHEMA: &str = "a3s.code.run-capability-binding.v1";
12pub const CAPABILITY_CEILING_DIGEST_DOMAIN: &str = "a3s.code.capability-ceiling-digest.v1";
13
14const CAPABILITY_CEILING_DIGEST_PREFIX: &[u8] = b"a3s-code-capability-ceiling\0";
15
16/// Stable validation and comparison failures for a persisted Run capability
17/// identity.
18#[derive(Clone, Debug, Eq, Error, PartialEq)]
19pub enum RunCapabilityBindingError {
20    #[error("invalid Run capability binding field '{field}': {message}")]
21    InvalidField {
22        field: &'static str,
23        message: String,
24    },
25    #[error("Run capability binding cannot encode its authority ceiling: {0}")]
26    Encoding(String),
27    #[error(
28        "Run capability binding drift (catalog generation {expected_generation} vs {actual_generation}, catalog digest mismatch: {catalog_digest_mismatch}, ceiling digest mismatch: {ceiling_digest_mismatch}, A3S Use generation mismatch: {use_generation_mismatch})"
29    )]
30    ContentDrift {
31        expected_generation: u64,
32        actual_generation: u64,
33        catalog_digest_mismatch: bool,
34        ceiling_digest_mismatch: bool,
35        use_generation_mismatch: bool,
36    },
37}
38
39/// Diagnostic copy of the exact upstream A3S Use snapshot cursor retained by
40/// the Run. The catalog digest already binds these fields; keeping them
41/// explicit lets a recovery host request the correct generation without
42/// resolving `latest`.
43#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase", deny_unknown_fields)]
45pub struct RunUseCapabilityGenerationV1 {
46    schema: String,
47    generation: u64,
48    revision: String,
49    registry_revision: String,
50}
51
52impl RunUseCapabilityGenerationV1 {
53    fn from_generation(generation: &UseCapabilityGeneration) -> Self {
54        Self {
55            schema: generation.schema().to_owned(),
56            generation: generation.generation(),
57            revision: generation.revision().to_string(),
58            registry_revision: generation.registry_revision().to_string(),
59        }
60    }
61
62    pub fn validate(&self) -> Result<(), RunCapabilityBindingError> {
63        ensure_schema(
64            "useGeneration.schema",
65            &self.schema,
66            USE_CAPABILITY_SNAPSHOT_CURSOR_SCHEMA,
67        )?;
68        ensure_digest("useGeneration.revision", &self.revision)?;
69        ensure_digest("useGeneration.registryRevision", &self.registry_revision)?;
70        Ok(())
71    }
72
73    pub fn schema(&self) -> &str {
74        &self.schema
75    }
76
77    pub const fn generation(&self) -> u64 {
78        self.generation
79    }
80
81    pub fn revision(&self) -> &str {
82        &self.revision
83    }
84
85    pub fn registry_revision(&self) -> &str {
86        &self.registry_revision
87    }
88}
89
90/// Serializable identity of the complete immutable capability generation and
91/// authority ceiling admitted for one Agent Run.
92///
93/// Runtime values, package paths, credentials, and leases are intentionally
94/// absent. A recovery host reconstructs those values, then Code verifies this
95/// identity before the target Run is created.
96#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase", deny_unknown_fields)]
98pub struct RunCapabilityBindingV1 {
99    schema: String,
100    capability_set_schema: String,
101    code_catalog_generation: u64,
102    catalog_digest: String,
103    capability_ceiling_schema: String,
104    capability_ceiling_digest: String,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    use_generation: Option<RunUseCapabilityGenerationV1>,
107}
108
109impl RunCapabilityBindingV1 {
110    pub fn from_set_and_ceiling(
111        set: &CapabilitySet,
112        ceiling: &CapabilityCeiling,
113    ) -> Result<Self, RunCapabilityBindingError> {
114        if ceiling.catalog_digest() != set.digest() {
115            return Err(invalid_field(
116                "capabilityCeiling",
117                "the authority ceiling belongs to another capability catalog",
118            ));
119        }
120        Ok(Self {
121            schema: RUN_CAPABILITY_BINDING_SCHEMA.to_owned(),
122            capability_set_schema: set.schema().to_owned(),
123            code_catalog_generation: set.generation().get(),
124            catalog_digest: set.digest().to_string(),
125            capability_ceiling_schema: ceiling.schema().to_owned(),
126            capability_ceiling_digest: ceiling_digest(ceiling)?.to_string(),
127            use_generation: set
128                .use_capability_generation()
129                .map(RunUseCapabilityGenerationV1::from_generation),
130        })
131    }
132
133    pub fn validate(&self) -> Result<(), RunCapabilityBindingError> {
134        ensure_schema("schema", &self.schema, RUN_CAPABILITY_BINDING_SCHEMA)?;
135        ensure_schema(
136            "capabilitySetSchema",
137            &self.capability_set_schema,
138            CAPABILITY_SET_SCHEMA,
139        )?;
140        ensure_digest("catalogDigest", &self.catalog_digest)?;
141        ensure_schema(
142            "capabilityCeilingSchema",
143            &self.capability_ceiling_schema,
144            CAPABILITY_CEILING_SCHEMA,
145        )?;
146        ensure_digest("capabilityCeilingDigest", &self.capability_ceiling_digest)?;
147        if let Some(use_generation) = &self.use_generation {
148            use_generation.validate()?;
149        }
150        Ok(())
151    }
152
153    pub fn ensure_matches(
154        &self,
155        set: &CapabilitySet,
156        ceiling: &CapabilityCeiling,
157    ) -> Result<(), RunCapabilityBindingError> {
158        self.validate()?;
159        let actual = Self::from_set_and_ceiling(set, ceiling)?;
160        if self == &actual {
161            return Ok(());
162        }
163        Err(RunCapabilityBindingError::ContentDrift {
164            expected_generation: self.code_catalog_generation,
165            actual_generation: actual.code_catalog_generation,
166            catalog_digest_mismatch: self.catalog_digest != actual.catalog_digest,
167            ceiling_digest_mismatch: self.capability_ceiling_digest
168                != actual.capability_ceiling_digest,
169            use_generation_mismatch: self.use_generation != actual.use_generation,
170        })
171    }
172
173    pub fn schema(&self) -> &str {
174        &self.schema
175    }
176
177    pub fn capability_set_schema(&self) -> &str {
178        &self.capability_set_schema
179    }
180
181    pub const fn code_catalog_generation(&self) -> u64 {
182        self.code_catalog_generation
183    }
184
185    pub fn catalog_digest(&self) -> &str {
186        &self.catalog_digest
187    }
188
189    pub fn capability_ceiling_schema(&self) -> &str {
190        &self.capability_ceiling_schema
191    }
192
193    pub fn capability_ceiling_digest(&self) -> &str {
194        &self.capability_ceiling_digest
195    }
196
197    pub fn use_generation(&self) -> Option<&RunUseCapabilityGenerationV1> {
198        self.use_generation.as_ref()
199    }
200}
201
202fn ceiling_digest(ceiling: &CapabilityCeiling) -> Result<Sha256Digest, RunCapabilityBindingError> {
203    let encoded = serde_json::to_vec(ceiling)
204        .map_err(|error| RunCapabilityBindingError::Encoding(error.to_string()))?;
205    if encoded.len() as u64 > MAX_CAPABILITY_CANONICAL_BYTES {
206        return Err(invalid_field(
207            "capabilityCeiling",
208            "the canonical authority ceiling exceeds the capability identity bound",
209        ));
210    }
211    let mut hasher = Sha256::new();
212    hasher.update(CAPABILITY_CEILING_DIGEST_PREFIX);
213    hasher.update(CAPABILITY_CEILING_DIGEST_DOMAIN.as_bytes());
214    hasher.update([0]);
215    hasher.update(encoded);
216    Sha256Digest::new(format!("sha256:{:x}", hasher.finalize()))
217        .map_err(|error| RunCapabilityBindingError::Encoding(error.to_string()))
218}
219
220fn ensure_schema(
221    field: &'static str,
222    actual: &str,
223    expected: &str,
224) -> Result<(), RunCapabilityBindingError> {
225    if actual == expected {
226        Ok(())
227    } else {
228        Err(invalid_field(field, "the schema is unsupported"))
229    }
230}
231
232fn ensure_digest(field: &'static str, value: &str) -> Result<(), RunCapabilityBindingError> {
233    Sha256Digest::new(value.to_owned())
234        .map(|_| ())
235        .map_err(|_| {
236            invalid_field(
237                field,
238                "the value is not a canonical lowercase SHA-256 digest",
239            )
240        })
241}
242
243fn invalid_field(field: &'static str, message: impl Into<String>) -> RunCapabilityBindingError {
244    RunCapabilityBindingError::InvalidField {
245        field,
246        message: message.into(),
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::capability::{
254        CapabilityContribution, CapabilityExecutionCeiling, CodeCatalogGeneration,
255        GovernanceCapabilityCeiling, WorkspaceCapabilityCeiling,
256    };
257
258    fn ceiling(set: &CapabilitySet, max_tool_rounds: usize) -> CapabilityCeiling {
259        CapabilityCeiling::all(
260            set,
261            WorkspaceCapabilityCeiling::all(),
262            GovernanceCapabilityCeiling::none_required(),
263            CapabilityExecutionCeiling::new(max_tool_rounds, 4, None, None, None).unwrap(),
264        )
265        .unwrap()
266    }
267
268    #[test]
269    fn binding_round_trips_and_rejects_catalog_or_ceiling_drift() {
270        let first = CapabilitySet::empty().unwrap();
271        let first_ceiling = ceiling(&first, 8);
272        let binding = RunCapabilityBindingV1::from_set_and_ceiling(&first, &first_ceiling).unwrap();
273        let encoded = serde_json::to_vec(&binding).unwrap();
274        let decoded: RunCapabilityBindingV1 = serde_json::from_slice(&encoded).unwrap();
275        assert_eq!(decoded, binding);
276        decoded.ensure_matches(&first, &first_ceiling).unwrap();
277
278        let second = CapabilitySet::from_contributions(
279            CodeCatalogGeneration::new(1),
280            Vec::<CapabilityContribution>::new(),
281        )
282        .unwrap();
283        assert!(matches!(
284            binding.ensure_matches(&second, &ceiling(&second, 8)),
285            Err(RunCapabilityBindingError::ContentDrift {
286                catalog_digest_mismatch: true,
287                ..
288            })
289        ));
290        assert!(matches!(
291            binding.ensure_matches(&first, &ceiling(&first, 7)),
292            Err(RunCapabilityBindingError::ContentDrift {
293                ceiling_digest_mismatch: true,
294                ..
295            })
296        ));
297    }
298
299    #[test]
300    fn malformed_persisted_digests_fail_validation() {
301        let set = CapabilitySet::empty().unwrap();
302        let mut value = serde_json::to_value(
303            RunCapabilityBindingV1::from_set_and_ceiling(&set, &ceiling(&set, 8)).unwrap(),
304        )
305        .unwrap();
306        value["catalogDigest"] = serde_json::Value::String("sha256:UPPER".into());
307        let malformed: RunCapabilityBindingV1 = serde_json::from_value(value).unwrap();
308        assert!(matches!(
309            malformed.validate(),
310            Err(RunCapabilityBindingError::InvalidField {
311                field: "catalogDigest",
312                ..
313            })
314        ));
315    }
316}