a3s_code_core/capability/
run_binding.rs1use 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#[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#[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#[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}