Skip to main content

openhawk_core/
talon.rs

1// Talon plugin system — interface, registry, signature verification, isolation
2
3use std::collections::HashMap;
4use std::panic;
5use std::sync::{Arc, Mutex};
6
7use crate::error::HawkError;
8
9pub type Result<T> = std::result::Result<T, TalonError>;
10
11#[derive(Debug, thiserror::Error)]
12pub enum TalonError {
13    #[error("signature verification failed for '{0}'")]
14    InvalidSignature(String),
15    #[error("talon not found: '{0}'")]
16    NotFound(String),
17    #[error("talon already installed: '{0}'")]
18    AlreadyInstalled(String),
19    #[error("talon lifecycle error: {0}")]
20    Lifecycle(String),
21    #[error("talon panicked: {0}")]
22    Panicked(String),
23}
24
25impl From<TalonError> for HawkError {
26    fn from(e: TalonError) -> Self {
27        HawkError::Config(e.to_string())
28    }
29}
30
31#[derive(Debug, Clone)]
32pub struct Capability {
33    pub name: String,
34    pub description: String,
35}
36
37#[derive(Debug, Clone, Default)]
38pub struct TalonConfig {
39    pub settings: HashMap<String, String>,
40}
41
42#[derive(Debug, Clone, PartialEq)]
43pub enum TalonStatus {
44    Loaded,
45    Unloaded,
46    Failed(String),
47}
48
49#[derive(Debug, Clone)]
50pub struct TalonRecord {
51    pub name: String,
52    pub version: String,
53    pub status: TalonStatus,
54    pub capabilities: Vec<Capability>,
55    pub signature: String,
56}
57
58pub trait Talon: Send + Sync {
59    fn name(&self) -> &str;
60    fn version(&self) -> &str;
61    fn load(&mut self) -> Result<()>;
62    fn unload(&mut self) -> Result<()>;
63    fn configure(&mut self, config: TalonConfig) -> Result<()>;
64    fn capabilities(&self) -> Vec<Capability>;
65}
66
67// ── Signature verification ────────────────────────────────────────────────────
68//
69// Production would use ed25519; here we use SHA-256 of "{name}:{version}" as a
70// hex string so tests can generate valid signatures without a key pair.
71
72fn expected_signature(name: &str, version: &str) -> String {
73    use sha2::{Digest, Sha256};
74    let mut h = Sha256::new();
75    h.update(format!("{name}:{version}").as_bytes());
76    hex::encode(h.finalize())
77}
78
79fn verify_signature(name: &str, version: &str, signature: &str) -> bool {
80    expected_signature(name, version) == signature
81}
82
83pub fn make_signature(name: &str, version: &str) -> String {
84    expected_signature(name, version)
85}
86
87// ── Registry ──────────────────────────────────────────────────────────────────
88
89pub struct TalonRegistry {
90    records: Arc<Mutex<HashMap<String, TalonRecord>>>,
91    instances: Arc<Mutex<HashMap<String, Box<dyn Talon>>>>,
92}
93
94impl TalonRegistry {
95    pub fn new() -> Self {
96        Self {
97            records: Arc::new(Mutex::new(HashMap::new())),
98            instances: Arc::new(Mutex::new(HashMap::new())),
99        }
100    }
101
102    pub fn install(&self, name: &str, version: &str, signature: &str, capabilities: Vec<Capability>) -> Result<()> {
103        if !verify_signature(name, version, signature) {
104            eprintln!("SECURITY WARNING: signature verification failed for talon '{name}'. Installation rejected.");
105            return Err(TalonError::InvalidSignature(name.to_string()));
106        }
107        let mut records = self.records.lock().unwrap();
108        if records.contains_key(name) {
109            return Err(TalonError::AlreadyInstalled(name.to_string()));
110        }
111        records.insert(name.to_string(), TalonRecord {
112            name: name.to_string(),
113            version: version.to_string(),
114            status: TalonStatus::Unloaded,
115            capabilities,
116            signature: signature.to_string(),
117        });
118        Ok(())
119    }
120
121    pub fn load(&self, name: &str) -> Result<()> {
122        let mut instances = self.instances.lock().unwrap();
123        let mut records = self.records.lock().unwrap();
124
125        let record = records.get_mut(name).ok_or_else(|| TalonError::NotFound(name.to_string()))?;
126
127        if let Some(t) = instances.get_mut(name) {
128            let t_ptr = t.as_mut() as *mut dyn Talon;
129            let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
130                // SAFETY: we hold the mutex for the duration of this call
131                unsafe { &mut *t_ptr }.load()
132            }));
133            match result {
134                Ok(Ok(())) => { record.status = TalonStatus::Loaded; Ok(()) }
135                Ok(Err(e)) => {
136                    let msg = e.to_string();
137                    record.status = TalonStatus::Failed(msg.clone());
138                    Err(TalonError::Lifecycle(msg))
139                }
140                Err(_) => {
141                    let msg = "talon panicked during load".to_string();
142                    record.status = TalonStatus::Failed(msg.clone());
143                    Err(TalonError::Panicked(msg))
144                }
145            }
146        } else {
147            // no concrete instance — mark as loaded (CLI path)
148            record.status = TalonStatus::Loaded;
149            Ok(())
150        }
151    }
152
153    pub fn unload(&self, name: &str) -> Result<()> {
154        let mut instances = self.instances.lock().unwrap();
155        let mut records = self.records.lock().unwrap();
156
157        let record = records.get_mut(name).ok_or_else(|| TalonError::NotFound(name.to_string()))?;
158
159        if let Some(t) = instances.get_mut(name) {
160            let t_ptr = t.as_mut() as *mut dyn Talon;
161            let result = panic::catch_unwind(panic::AssertUnwindSafe(|| unsafe { &mut *t_ptr }.unload()));
162            match result {
163                Ok(Ok(())) => {}
164                Ok(Err(e)) => {
165                    let msg = e.to_string();
166                    record.status = TalonStatus::Failed(msg.clone());
167                    return Err(TalonError::Lifecycle(msg));
168                }
169                Err(_) => {
170                    let msg = "talon panicked during unload".to_string();
171                    record.status = TalonStatus::Failed(msg.clone());
172                    return Err(TalonError::Panicked(msg));
173                }
174            }
175        }
176
177        record.status = TalonStatus::Unloaded;
178        Ok(())
179    }
180
181    pub fn list(&self) -> Vec<TalonRecord> {
182        self.records.lock().unwrap().values().cloned().collect()
183    }
184
185    pub fn get_capabilities(&self, name: &str) -> Option<Vec<Capability>> {
186        self.records.lock().unwrap().get(name).map(|r| r.capabilities.clone())
187    }
188
189    pub fn is_authorized(agent_manifest_talons: &[String], talon_name: &str) -> bool {
190        agent_manifest_talons.iter().any(|t| t == talon_name)
191    }
192
193    pub fn register_instance(&self, talon: Box<dyn Talon>) -> Result<()> {
194        let name = talon.name().to_string();
195        self.instances.lock().unwrap().insert(name, talon);
196        Ok(())
197    }
198}
199
200impl Default for TalonRegistry {
201    fn default() -> Self {
202        Self::new()
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    struct GoodTalon { loaded: bool }
211    impl GoodTalon { fn new() -> Self { Self { loaded: false } } }
212    impl Talon for GoodTalon {
213        fn name(&self) -> &str { "good-talon" }
214        fn version(&self) -> &str { "1.0.0" }
215        fn load(&mut self) -> Result<()> { self.loaded = true; Ok(()) }
216        fn unload(&mut self) -> Result<()> { self.loaded = false; Ok(()) }
217        fn configure(&mut self, _: TalonConfig) -> Result<()> { Ok(()) }
218        fn capabilities(&self) -> Vec<Capability> {
219            vec![Capability { name: "browse".to_string(), description: "web browsing".to_string() }]
220        }
221    }
222
223    struct PanickingTalon;
224    impl Talon for PanickingTalon {
225        fn name(&self) -> &str { "panic-talon" }
226        fn version(&self) -> &str { "0.1.0" }
227        fn load(&mut self) -> Result<()> { panic!("intentional panic for isolation test"); }
228        fn unload(&mut self) -> Result<()> { Ok(()) }
229        fn configure(&mut self, _: TalonConfig) -> Result<()> { Ok(()) }
230        fn capabilities(&self) -> Vec<Capability> { vec![] }
231    }
232
233    struct FailingTalon;
234    impl Talon for FailingTalon {
235        fn name(&self) -> &str { "fail-talon" }
236        fn version(&self) -> &str { "0.1.0" }
237        fn load(&mut self) -> Result<()> { Err(TalonError::Lifecycle("load failed".to_string())) }
238        fn unload(&mut self) -> Result<()> { Ok(()) }
239        fn configure(&mut self, _: TalonConfig) -> Result<()> { Ok(()) }
240        fn capabilities(&self) -> Vec<Capability> { vec![] }
241    }
242
243    fn install_good(registry: &TalonRegistry) {
244        let sig = make_signature("good-talon", "1.0.0");
245        registry.install("good-talon", "1.0.0", &sig, vec![
246            Capability { name: "browse".to_string(), description: "web browsing".to_string() },
247        ]).unwrap();
248    }
249
250    #[test]
251    fn load_and_unload_lifecycle() {
252        let registry = TalonRegistry::new();
253        install_good(&registry);
254        registry.register_instance(Box::new(GoodTalon::new())).unwrap();
255        registry.load("good-talon").unwrap();
256        assert_eq!(registry.records.lock().unwrap()["good-talon"].status, TalonStatus::Loaded);
257        registry.unload("good-talon").unwrap();
258        assert_eq!(registry.records.lock().unwrap()["good-talon"].status, TalonStatus::Unloaded);
259    }
260
261    #[test]
262    fn valid_signature_accepted() {
263        let registry = TalonRegistry::new();
264        let sig = make_signature("my-talon", "2.0.0");
265        assert!(registry.install("my-talon", "2.0.0", &sig, vec![]).is_ok());
266    }
267
268    #[test]
269    fn invalid_signature_rejected() {
270        let registry = TalonRegistry::new();
271        assert!(matches!(registry.install("my-talon", "2.0.0", "bad-sig", vec![]), Err(TalonError::InvalidSignature(_))));
272    }
273
274    #[test]
275    fn wrong_version_signature_rejected() {
276        let registry = TalonRegistry::new();
277        let sig = make_signature("my-talon", "1.0.0");
278        assert!(matches!(registry.install("my-talon", "2.0.0", &sig, vec![]), Err(TalonError::InvalidSignature(_))));
279    }
280
281    #[test]
282    fn panicking_talon_does_not_crash_process() {
283        let registry = TalonRegistry::new();
284        let sig = make_signature("panic-talon", "0.1.0");
285        registry.install("panic-talon", "0.1.0", &sig, vec![]).unwrap();
286        registry.register_instance(Box::new(PanickingTalon)).unwrap();
287        let result = registry.load("panic-talon");
288        assert!(matches!(result, Err(TalonError::Panicked(_))));
289        assert!(matches!(registry.records.lock().unwrap()["panic-talon"].status, TalonStatus::Failed(_)));
290    }
291
292    #[test]
293    fn failing_talon_is_marked_failed() {
294        let registry = TalonRegistry::new();
295        let sig = make_signature("fail-talon", "0.1.0");
296        registry.install("fail-talon", "0.1.0", &sig, vec![]).unwrap();
297        registry.register_instance(Box::new(FailingTalon)).unwrap();
298        let result = registry.load("fail-talon");
299        assert!(matches!(result, Err(TalonError::Lifecycle(_))));
300        assert!(matches!(registry.records.lock().unwrap()["fail-talon"].status, TalonStatus::Failed(_)));
301    }
302
303    #[test]
304    fn authorized_agent_can_use_talon() {
305        let declared = vec!["browser-talon".to_string(), "github-talon".to_string()];
306        assert!(TalonRegistry::is_authorized(&declared, "browser-talon"));
307    }
308
309    #[test]
310    fn unauthorized_agent_cannot_use_talon() {
311        let declared = vec!["github-talon".to_string()];
312        assert!(!TalonRegistry::is_authorized(&declared, "browser-talon"));
313    }
314
315    #[test]
316    fn empty_manifest_talons_denies_all() {
317        assert!(!TalonRegistry::is_authorized(&[], "any-talon"));
318    }
319
320    #[test]
321    fn get_capabilities_returns_installed_caps() {
322        let registry = TalonRegistry::new();
323        install_good(&registry);
324        let caps = registry.get_capabilities("good-talon").unwrap();
325        assert_eq!(caps.len(), 1);
326        assert_eq!(caps[0].name, "browse");
327    }
328
329    #[test]
330    fn get_capabilities_returns_none_for_unknown() {
331        let registry = TalonRegistry::new();
332        assert!(registry.get_capabilities("unknown").is_none());
333    }
334
335    #[test]
336    fn list_returns_all_installed() {
337        let registry = TalonRegistry::new();
338        install_good(&registry);
339        let sig2 = make_signature("other-talon", "0.5.0");
340        registry.install("other-talon", "0.5.0", &sig2, vec![]).unwrap();
341        assert_eq!(registry.list().len(), 2);
342    }
343
344    #[test]
345    fn duplicate_install_rejected() {
346        let registry = TalonRegistry::new();
347        install_good(&registry);
348        let sig = make_signature("good-talon", "1.0.0");
349        assert!(matches!(registry.install("good-talon", "1.0.0", &sig, vec![]), Err(TalonError::AlreadyInstalled(_))));
350    }
351
352    #[test]
353    fn load_unknown_talon_returns_not_found() {
354        let registry = TalonRegistry::new();
355        assert!(matches!(registry.load("ghost"), Err(TalonError::NotFound(_))));
356    }
357}