1use buffa::Message as _;
23use polyc_proto::proto::polychrome::subagent::v1::{SubagentResult, SubagentSpawn};
24
25use crate::signing_role::{RoleTrustSet, SignatureVerdict, SubagentRole, SubagentSigner};
26
27fn spawn_canonical_bytes(spawn: &SubagentSpawn) -> Vec<u8> {
31 let mut canonical = spawn.clone();
32 canonical.signature_hex.clear();
33 canonical.encode_to_vec()
34}
35
36fn result_canonical_bytes(result: &SubagentResult) -> Vec<u8> {
39 let mut canonical = result.clone();
40 canonical.signature_hex.clear();
41 canonical.encode_to_vec()
42}
43
44pub fn sign_subagent_spawn_into(signer: &SubagentSigner, spawn: &mut SubagentSpawn) {
47 spawn.signed_by = signer.public_key_bytes();
48 spawn.signature_hex.clear();
49 let signature = signer.sign_subagent_spawn(&spawn_canonical_bytes(spawn));
50 spawn.signature_hex = crate::hex::lower(&signature);
51}
52
53pub fn sign_subagent_result_into(signer: &SubagentSigner, result: &mut SubagentResult) {
56 result.signed_by = signer.public_key_bytes();
57 result.signature_hex.clear();
58 let signature = signer.sign_subagent_result(&result_canonical_bytes(result));
59 result.signature_hex = crate::hex::lower(&signature);
60}
61
62#[must_use]
71pub fn classify_subagent_spawn(
72 trust: &RoleTrustSet<SubagentRole>,
73 spawn: &SubagentSpawn,
74) -> SignatureVerdict {
75 let Some(signature) = crate::hex::decode(&spawn.signature_hex) else {
76 return SignatureVerdict::Invalid;
77 };
78 trust.classify_subagent_spawn(&spawn.signed_by, &spawn_canonical_bytes(spawn), &signature)
79}
80
81#[must_use]
86pub fn classify_subagent_result(
87 trust: &RoleTrustSet<SubagentRole>,
88 result: &SubagentResult,
89) -> SignatureVerdict {
90 let Some(signature) = crate::hex::decode(&result.signature_hex) else {
91 return SignatureVerdict::Invalid;
92 };
93 trust.classify_subagent_result(
94 &result.signed_by,
95 &result_canonical_bytes(result),
96 &signature,
97 )
98}
99
100#[cfg(test)]
101mod tests {
102 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
103
104 use super::*;
105
106 fn trust(signer: &SubagentSigner) -> RoleTrustSet<SubagentRole> {
108 RoleTrustSet::current(signer)
109 }
110
111 fn sample_spawn() -> SubagentSpawn {
112 SubagentSpawn {
113 sub_agent_id: "call-7".to_owned(),
114 target_agent_id: "researcher".to_owned(),
115 resolved_provider: "vertex".to_owned(),
116 resolved_model: "fable-pro".to_owned(),
117 task: "find prior art".to_owned(),
118 ..Default::default()
119 }
120 }
121
122 fn sample_result() -> SubagentResult {
123 SubagentResult {
124 sub_agent_id: "call-7".to_owned(),
125 target_agent_id: "researcher".to_owned(),
126 succeeded: true,
127 error: String::new(),
128 input_tokens: 42,
129 output_tokens: 7,
130 first_party: true,
131 ..Default::default()
132 }
133 }
134
135 #[test]
136 fn spawn_round_trips() {
137 let signer = SubagentSigner::from_seed(21);
138 let mut s = sample_spawn();
139 sign_subagent_spawn_into(&signer, &mut s);
140 assert!(!s.signature_hex.is_empty());
141 assert_eq!(s.signed_by, signer.public_key_bytes());
142 assert_eq!(
143 classify_subagent_spawn(&trust(&signer), &s),
144 SignatureVerdict::Verified
145 );
146 }
147
148 #[test]
149 fn spawn_tampered_task_fails() {
150 let signer = SubagentSigner::from_seed(21);
151 let mut s = sample_spawn();
152 sign_subagent_spawn_into(&signer, &mut s);
153 s.task = "leaked task".to_owned();
154 assert_eq!(
155 classify_subagent_spawn(&trust(&signer), &s),
156 SignatureVerdict::Invalid
157 );
158 }
159
160 #[test]
161 fn spawn_from_an_untrusted_signer_reads_untrusted() {
162 let signer = SubagentSigner::from_seed(21);
163 let other = SubagentSigner::from_seed(22);
164 let mut s = sample_spawn();
165 sign_subagent_spawn_into(&signer, &mut s);
166 assert_eq!(
167 classify_subagent_spawn(&trust(&other), &s),
168 SignatureVerdict::Untrusted
169 );
170 }
171
172 #[test]
173 fn spawn_unsigned_fails() {
174 let signer = SubagentSigner::from_seed(21);
175 let s = sample_spawn();
176 assert_eq!(
177 classify_subagent_spawn(&trust(&signer), &s),
178 SignatureVerdict::Invalid
179 );
180 }
181
182 #[test]
183 fn result_round_trips() {
184 let signer = SubagentSigner::from_seed(23);
185 let mut r = sample_result();
186 sign_subagent_result_into(&signer, &mut r);
187 assert!(!r.signature_hex.is_empty());
188 assert_eq!(r.signed_by, signer.public_key_bytes());
189 assert_eq!(
190 classify_subagent_result(&trust(&signer), &r),
191 SignatureVerdict::Verified
192 );
193 }
194
195 #[test]
196 fn result_tampered_usage_fails() {
197 let signer = SubagentSigner::from_seed(23);
198 let mut r = sample_result();
199 sign_subagent_result_into(&signer, &mut r);
200 r.input_tokens = 999_999;
201 assert_eq!(
202 classify_subagent_result(&trust(&signer), &r),
203 SignatureVerdict::Invalid
204 );
205 }
206
207 #[test]
208 fn result_tampered_succeeded_fails() {
209 let signer = SubagentSigner::from_seed(23);
210 let mut r = sample_result();
211 sign_subagent_result_into(&signer, &mut r);
212 r.succeeded = false;
213 r.error = "worker turn failed: forged".to_owned();
214 assert_eq!(
215 classify_subagent_result(&trust(&signer), &r),
216 SignatureVerdict::Invalid
217 );
218 }
219
220 #[test]
221 fn tampered_first_party_fails_verification() {
222 let signer = SubagentSigner::from_seed(23);
223 let mut r = sample_result();
224 sign_subagent_result_into(&signer, &mut r);
225 r.first_party = false;
226 assert_eq!(
227 classify_subagent_result(&trust(&signer), &r),
228 SignatureVerdict::Invalid
229 );
230 }
231
232 #[test]
233 fn result_from_an_untrusted_signer_reads_untrusted() {
234 let signer = SubagentSigner::from_seed(23);
235 let other = SubagentSigner::from_seed(24);
236 let mut r = sample_result();
237 sign_subagent_result_into(&signer, &mut r);
238 assert_eq!(
239 classify_subagent_result(&trust(&other), &r),
240 SignatureVerdict::Untrusted
241 );
242 }
243
244 #[test]
245 fn result_unsigned_fails() {
246 let signer = SubagentSigner::from_seed(23);
247 let r = sample_result();
248 assert_eq!(
249 classify_subagent_result(&trust(&signer), &r),
250 SignatureVerdict::Invalid
251 );
252 }
253
254 #[test]
255 fn garbage_signature_hex_reads_invalid() {
256 let pinned = trust(&SubagentSigner::from_seed(25));
257 let mut s = sample_spawn();
258 s.signature_hex = "not-hex!".to_owned();
259 assert_eq!(
260 classify_subagent_spawn(&pinned, &s),
261 SignatureVerdict::Invalid
262 );
263 s.signature_hex = "abcd".to_owned();
264 assert_eq!(
265 classify_subagent_spawn(&pinned, &s),
266 SignatureVerdict::Invalid
267 );
268 }
269
270 #[test]
276 fn a_spawn_signature_does_not_verify_as_a_result() {
277 let signer = SubagentSigner::from_seed(26);
278 let mut spawn = sample_spawn();
279 sign_subagent_spawn_into(&signer, &mut spawn);
280
281 let replayed = SubagentResult {
282 sub_agent_id: spawn.sub_agent_id.clone(),
283 target_agent_id: spawn.target_agent_id.clone(),
284 signed_by: spawn.signed_by.clone(),
285 signature_hex: spawn.signature_hex.clone(),
286 ..Default::default()
287 };
288 assert_eq!(
289 classify_subagent_result(&trust(&signer), &replayed),
290 SignatureVerdict::Invalid
291 );
292 assert_eq!(
293 classify_subagent_spawn(&trust(&signer), &spawn),
294 SignatureVerdict::Verified
295 );
296
297 let mut result = sample_result();
298 sign_subagent_result_into(&signer, &mut result);
299 let replayed = SubagentSpawn {
300 sub_agent_id: result.sub_agent_id.clone(),
301 target_agent_id: result.target_agent_id.clone(),
302 signed_by: result.signed_by.clone(),
303 signature_hex: result.signature_hex.clone(),
304 ..Default::default()
305 };
306 assert_eq!(
307 classify_subagent_spawn(&trust(&signer), &replayed),
308 SignatureVerdict::Invalid
309 );
310 }
311}