1use crate::effects::EffectRow;
12use hmac::{Hmac, Mac};
13use sha2::{Digest, Sha256};
14
15type HmacSha256 = Hmac<Sha256>;
16
17#[must_use]
23pub fn sign_hmac(payload: &str, key: &[u8]) -> Option<String> {
24 let Ok(mut mac) = HmacSha256::new_from_slice(key) else {
25 return None;
26 };
27 mac.update(payload.as_bytes());
28 Some(format!("{:x}", mac.finalize().into_bytes()))
29}
30
31#[must_use]
35pub fn verify_hmac(payload: &str, signature: &str, key: &[u8]) -> bool {
36 if signature.is_empty() {
37 return false;
38 }
39 sign_hmac(payload, key).is_some_and(|expected| expected == signature)
40}
41
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
44pub struct ToolManifest {
45 pub tool_name: String,
47 pub version: String,
49 pub publisher: String,
51 pub description: String,
53 #[serde(default)]
55 pub effects: EffectSummary,
56 pub capabilities: Vec<String>,
58 pub trust_level: f32,
60 pub requires_human_review: bool,
62 pub created_at: i64,
64 #[serde(default)]
66 pub signature: String,
67}
68
69#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
71pub struct EffectSummary {
72 #[serde(default)]
74 pub reads: Vec<String>,
75 #[serde(default)]
77 pub writes: Vec<String>,
78 #[serde(default)]
80 pub spawns: bool,
81}
82
83impl EffectSummary {
84 #[must_use]
86 pub fn from_effect_row(effects: &EffectRow) -> Self {
87 Self {
88 reads: effects.reads.iter().map(|r| format!("{r:?}")).collect(),
89 writes: effects.writes.iter().map(|r| format!("{r:?}")).collect(),
90 spawns: effects.spawns,
91 }
92 }
93
94 #[must_use]
96 pub fn has_destructive_effects(&self) -> bool {
97 !self.writes.is_empty() || self.spawns
98 }
99}
100
101impl ToolManifest {
102 #[must_use]
104 pub fn new(tool_name: &str, version: &str, publisher: &str, description: &str) -> Self {
105 Self {
106 tool_name: tool_name.to_string(),
107 version: version.to_string(),
108 publisher: publisher.to_string(),
109 description: description.to_string(),
110 effects: EffectSummary::default(),
111 capabilities: Vec::new(),
112 trust_level: 0.5,
113 requires_human_review: false,
114 created_at: chrono::Utc::now().timestamp(),
115 signature: String::new(),
116 }
117 }
118
119 #[must_use]
121 pub fn with_effects(mut self, effects: EffectSummary) -> Self {
122 self.effects = effects;
123 self
124 }
125
126 #[must_use]
128 pub fn with_capabilities(mut self, caps: Vec<String>) -> Self {
129 self.capabilities = caps;
130 self
131 }
132
133 #[must_use]
135 pub const fn with_trust(mut self, trust: f32) -> Self {
136 self.trust_level = trust.clamp(0.0, 1.0);
137 self
138 }
139
140 #[must_use]
142 pub const fn require_review(mut self) -> Self {
143 self.requires_human_review = true;
144 self
145 }
146
147 #[must_use]
149 pub fn signing_payload(&self) -> String {
150 let without_sig = Self {
152 signature: String::new(),
153 ..self.clone()
154 };
155 serde_json::to_string(&without_sig).unwrap_or_default()
156 }
157
158 #[must_use]
162 pub fn sign(mut self, key: &[u8]) -> Self {
163 let payload = self.signing_payload();
164 if let Some(sig) = sign_hmac(&payload, key) {
165 self.signature = sig;
166 }
167 self
168 }
169
170 #[must_use]
174 pub fn verify_signature(&self, key: &[u8]) -> bool {
175 verify_hmac(&self.signing_payload(), &self.signature, key)
176 }
177
178 #[must_use]
180 pub fn has_capability(&self, cap: &str) -> bool {
181 self.capabilities.iter().any(|c| c == cap)
182 }
183
184 #[must_use]
186 pub fn is_destructive(&self) -> bool {
187 self.effects.has_destructive_effects()
188 }
189}
190
191#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
193pub struct TrustScope {
194 pub name: String,
196 pub min_trust: f32,
198 #[serde(default)]
200 pub allowed_tools: Vec<String>,
201 #[serde(default)]
203 pub denied_tools: Vec<String>,
204 pub allow_destructive: bool,
206 pub allow_network: bool,
208 pub allow_filesystem_write: bool,
210 pub require_review: bool,
212 pub max_calls_per_minute: u32,
214}
215
216impl Default for TrustScope {
217 fn default() -> Self {
218 Self {
219 name: "default".into(),
220 min_trust: 0.5,
221 allowed_tools: Vec::new(),
222 denied_tools: Vec::new(),
223 allow_destructive: false,
224 allow_network: false,
225 allow_filesystem_write: false,
226 require_review: false,
227 max_calls_per_minute: 60,
228 }
229 }
230}
231
232impl TrustScope {
233 #[must_use]
235 pub fn strict() -> Self {
236 Self {
237 name: "strict".into(),
238 min_trust: 0.8,
239 allowed_tools: vec!["memory.search".into(), "memory.recall".into()],
240 denied_tools: vec!["file.".into(), "process.".into(), "network.".into()],
241 allow_destructive: false,
242 allow_network: false,
243 allow_filesystem_write: false,
244 require_review: true,
245 max_calls_per_minute: 10,
246 }
247 }
248
249 #[must_use]
251 pub fn permissive() -> Self {
252 Self {
253 name: "permissive".into(),
254 min_trust: 0.3,
255 allow_destructive: true,
256 allow_network: true,
257 allow_filesystem_write: true,
258 require_review: false,
259 max_calls_per_minute: 200,
260 ..Self::default()
261 }
262 }
263
264 #[must_use]
266 pub fn is_tool_allowed(&self, manifest: &ToolManifest) -> bool {
267 if manifest.trust_level < self.min_trust {
269 return false;
270 }
271
272 if self
274 .denied_tools
275 .iter()
276 .any(|p| manifest.tool_name.starts_with(p))
277 {
278 return false;
279 }
280
281 if !self.allowed_tools.is_empty()
283 && !self
284 .allowed_tools
285 .iter()
286 .any(|p| manifest.tool_name.starts_with(p))
287 {
288 return false;
289 }
290
291 if manifest.is_destructive() && !self.allow_destructive {
293 return false;
294 }
295
296 if manifest.has_capability("network_access") && !self.allow_network {
298 return false;
299 }
300
301 if manifest.has_capability("filesystem_write") && !self.allow_filesystem_write {
303 return false;
304 }
305
306 if self.require_review && !manifest.requires_human_review {
308 }
311
312 true
313 }
314}
315
316pub struct ToolAttestationRegistry {
318 manifests: ahash::AHashMap<String, ToolManifest>,
320 signing_key: Vec<u8>,
322 external_scope: TrustScope,
324 trusted_publishers: Vec<String>,
326}
327
328impl ToolAttestationRegistry {
329 #[must_use]
331 pub fn new(signing_key: Vec<u8>) -> Self {
332 Self {
333 manifests: ahash::AHashMap::new(),
334 signing_key,
335 external_scope: TrustScope::default(),
336 trusted_publishers: vec!["whitemagic-core".into()],
337 }
338 }
339
340 #[must_use]
342 pub fn with_external_scope(mut self, scope: TrustScope) -> Self {
343 self.external_scope = scope;
344 self
345 }
346
347 pub fn trust_publisher(&mut self, publisher: &str) {
349 if !self.trusted_publishers.contains(&publisher.to_string()) {
350 self.trusted_publishers.push(publisher.to_string());
351 }
352 }
353
354 pub fn register(&mut self, manifest: ToolManifest) -> bool {
359 if !manifest.verify_signature(&self.signing_key) {
361 return false;
362 }
363
364 if !self.trusted_publishers.contains(&manifest.publisher) {
366 return false;
367 }
368
369 self.manifests.insert(manifest.tool_name.clone(), manifest);
370 true
371 }
372
373 pub fn register_unsigned(&mut self, manifest: ToolManifest) -> bool {
377 if !self.trusted_publishers.contains(&manifest.publisher) {
378 return false;
379 }
380 self.manifests.insert(manifest.tool_name.clone(), manifest);
381 true
382 }
383
384 #[must_use]
386 pub fn get(&self, tool_name: &str) -> Option<&ToolManifest> {
387 self.manifests.get(tool_name)
388 }
389
390 #[must_use]
392 pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
393 let Some(manifest) = self.manifests.get(tool_name) else {
394 return false; };
396
397 if manifest.publisher == "whitemagic-core" {
399 return true;
400 }
401
402 self.external_scope.is_tool_allowed(manifest)
404 }
405
406 #[must_use]
408 pub fn verify_provenance(&self, manifest: &ToolManifest) -> bool {
409 manifest.verify_signature(&self.signing_key)
410 && self.trusted_publishers.contains(&manifest.publisher)
411 }
412
413 #[must_use]
415 pub fn registered_tools(&self) -> Vec<String> {
416 self.manifests.keys().cloned().collect()
417 }
418
419 #[must_use]
421 pub fn len(&self) -> usize {
422 self.manifests.len()
423 }
424
425 #[must_use]
427 pub fn is_empty(&self) -> bool {
428 self.manifests.is_empty()
429 }
430}
431
432#[must_use]
434pub fn manifest_hash(manifest: &ToolManifest) -> String {
435 let payload = manifest.signing_payload();
436 let mut hasher = Sha256::new();
437 hasher.update(payload.as_bytes());
438 format!("{:x}", hasher.finalize())
439}
440
441#[cfg(test)]
444mod tests {
445 use super::*;
446
447 const TEST_KEY: &[u8] = b"test_signing_key_123";
448
449 fn make_manifest(name: &str, publisher: &str) -> ToolManifest {
450 ToolManifest::new(name, "1.0.0", publisher, "A test tool")
451 .with_trust(0.8)
452 .with_capabilities(vec!["read_only".into()])
453 }
454
455 #[test]
456 fn manifest_sign_and_verify() {
457 let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
458 assert!(
459 manifest.verify_signature(TEST_KEY),
460 "Signed manifest should verify"
461 );
462 }
463
464 #[test]
465 fn manifest_tamper_detected() {
466 let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
467 let tampered = ToolManifest {
468 description: "Tampered description".into(),
469 ..manifest
470 };
471 assert!(
472 !tampered.verify_signature(TEST_KEY),
473 "Tampered manifest should fail verification"
474 );
475 }
476
477 #[test]
478 fn manifest_unsigned_fails_verification() {
479 let manifest = make_manifest("memory.search", "whitemagic-core");
480 assert!(!manifest.verify_signature(TEST_KEY));
481 }
482
483 #[test]
484 fn manifest_wrong_key_fails() {
485 let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
486 assert!(!manifest.verify_signature(b"wrong_key"));
487 }
488
489 #[test]
490 fn manifest_has_capability() {
491 let manifest = make_manifest("memory.search", "whitemagic-core")
492 .with_capabilities(vec!["read_only".into(), "search".into()]);
493 assert!(manifest.has_capability("read_only"));
494 assert!(manifest.has_capability("search"));
495 assert!(!manifest.has_capability("network_access"));
496 }
497
498 #[test]
499 fn manifest_destructive_detection() {
500 let manifest = ToolManifest::new("file.write", "1.0.0", "whitemagic-core", "Write file")
501 .with_effects(EffectSummary {
502 writes: vec!["Filesystem".into()],
503 ..Default::default()
504 });
505 assert!(manifest.is_destructive());
506
507 let read_only = ToolManifest::new("memory.search", "1.0.0", "whitemagic-core", "Search")
508 .with_effects(EffectSummary {
509 reads: vec!["Galaxy".into()],
510 ..Default::default()
511 });
512 assert!(!read_only.is_destructive());
513 }
514
515 #[test]
516 fn trust_scope_default_allows_trusted() {
517 let scope = TrustScope::default();
518 let manifest = make_manifest("memory.search", "whitemagic-core").with_trust(0.6);
519 assert!(scope.is_tool_allowed(&manifest));
520 }
521
522 #[test]
523 fn trust_scope_blocks_low_trust() {
524 let scope = TrustScope::default();
525 let manifest = make_manifest("memory.search", "external").with_trust(0.2);
526 assert!(!scope.is_tool_allowed(&manifest));
527 }
528
529 #[test]
530 fn trust_scope_strict_blocks_destructive() {
531 let scope = TrustScope::strict();
532 let manifest = ToolManifest::new("file.write", "1.0.0", "external", "Write file")
533 .with_trust(0.9)
534 .with_effects(EffectSummary {
535 writes: vec!["Filesystem".into()],
536 ..Default::default()
537 });
538 assert!(!scope.is_tool_allowed(&manifest));
539 }
540
541 #[test]
542 fn trust_scope_strict_blocks_network() {
543 let scope = TrustScope::strict();
544 let manifest = ToolManifest::new("http.fetch", "1.0.0", "external", "Fetch URL")
545 .with_trust(0.9)
546 .with_capabilities(vec!["network_access".into()]);
547 assert!(!scope.is_tool_allowed(&manifest));
548 }
549
550 #[test]
551 fn trust_scope_denied_list_takes_precedence() {
552 let scope = TrustScope {
553 allowed_tools: vec!["memory.".into()],
554 denied_tools: vec!["memory.delete".into()],
555 ..TrustScope::permissive()
556 };
557 let manifest = make_manifest("memory.delete", "external").with_trust(0.9);
558 assert!(!scope.is_tool_allowed(&manifest));
559 }
560
561 #[test]
562 fn registry_register_signed_manifest() {
563 let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
564 let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
565 assert!(registry.register(manifest));
566 assert_eq!(registry.len(), 1);
567 }
568
569 #[test]
570 fn registry_rejects_invalid_signature() {
571 let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
572 let manifest = make_manifest("memory.search", "whitemagic-core").sign(b"wrong_key");
573 assert!(!registry.register(manifest));
574 assert_eq!(registry.len(), 0);
575 }
576
577 #[test]
578 fn registry_rejects_untrusted_publisher() {
579 let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
580 let manifest = make_manifest("memory.search", "untrusted").sign(TEST_KEY);
581 assert!(!registry.register(manifest));
582 }
583
584 #[test]
585 fn registry_allows_trusted_publisher() {
586 let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
587 registry.trust_publisher("external:acme");
588 let manifest = make_manifest("custom.tool", "external:acme").sign(TEST_KEY);
589 assert!(registry.register(manifest));
590 }
591
592 #[test]
593 fn registry_internal_tools_bypass_scope() {
594 let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec())
595 .with_external_scope(TrustScope::strict());
596 let manifest = ToolManifest::new("file.write", "1.0.0", "whitemagic-core", "Write")
597 .with_trust(0.5)
598 .with_effects(EffectSummary {
599 writes: vec!["Filesystem".into()],
600 ..Default::default()
601 })
602 .sign(TEST_KEY);
603 registry.register(manifest);
604
605 assert!(registry.is_tool_allowed("file.write"));
607 }
608
609 #[test]
610 fn registry_external_tools_checked_against_scope() {
611 let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec())
612 .with_external_scope(TrustScope::strict());
613 registry.trust_publisher("external:acme");
614 let manifest = ToolManifest::new("file.write", "1.0.0", "external:acme", "Write")
615 .with_trust(0.9)
616 .with_effects(EffectSummary {
617 writes: vec!["Filesystem".into()],
618 ..Default::default()
619 })
620 .sign(TEST_KEY);
621 registry.register(manifest);
622
623 assert!(!registry.is_tool_allowed("file.write"));
625 }
626
627 #[test]
628 fn registry_unknown_tool_not_allowed() {
629 let registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
630 assert!(!registry.is_tool_allowed("unknown.tool"));
631 }
632
633 #[test]
634 fn registry_verify_provenance() {
635 let registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
636 let manifest = make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY);
637 assert!(registry.verify_provenance(&manifest));
638
639 let untrusted = make_manifest("memory.search", "untrusted").sign(TEST_KEY);
640 assert!(!registry.verify_provenance(&untrusted));
641 }
642
643 #[test]
644 fn manifest_hash_deterministic() {
645 let m1 = make_manifest("memory.search", "whitemagic-core");
646 let m2 = make_manifest("memory.search", "whitemagic-core");
647 assert_eq!(manifest_hash(&m1), manifest_hash(&m2));
648 }
649
650 #[test]
651 fn manifest_hash_changes_with_content() {
652 let m1 = make_manifest("memory.search", "whitemagic-core");
653 let m2 = make_manifest("memory.search", "whitemagic-core").with_trust(0.9);
654 assert_ne!(manifest_hash(&m1), manifest_hash(&m2));
655 }
656
657 #[test]
658 fn registry_registered_tools_list() {
659 let mut registry = ToolAttestationRegistry::new(TEST_KEY.to_vec());
660 registry.register(make_manifest("memory.search", "whitemagic-core").sign(TEST_KEY));
661 registry.register(make_manifest("memory.recall", "whitemagic-core").sign(TEST_KEY));
662
663 let tools = registry.registered_tools();
664 assert_eq!(tools.len(), 2);
665 assert!(tools.contains(&"memory.search".to_string()));
666 assert!(tools.contains(&"memory.recall".to_string()));
667 }
668
669 #[test]
670 fn trust_scope_permissive_allows_most() {
671 let scope = TrustScope::permissive();
672 let manifest = ToolManifest::new("file.write", "1.0.0", "external", "Write")
673 .with_trust(0.5)
674 .with_effects(EffectSummary {
675 writes: vec!["Filesystem".into()],
676 ..Default::default()
677 })
678 .with_capabilities(vec!["filesystem_write".into()]);
679 assert!(scope.is_tool_allowed(&manifest));
680 }
681}