Skip to main content

lean_ctx/core/addons/
integrity.rs

1//! Addon integrity pinning + local re-verify (P2 — the lockfile half).
2//!
3//! `installed.json` is the lockfile: at install time we pin a content hash of
4//! the exact gateway wiring an addon installed (transport, command, args, env,
5//! url, headers, capabilities). [`verify_all`] re-computes that hash from the
6//! live `[[gateway.servers]]` config and reports any drift — so a swapped
7//! command, an added arg, or a widened capability after install is caught,
8//! complementing the [`super::revocation`] deny-list with a positive integrity
9//! check.
10//!
11//! (Pulling a newer *signed* version — the "updater" — is registry-server work
12//! that reuses the ctxpkg remote rails; this module is the local lock + verify
13//! it builds on.)
14
15use crate::core::gateway::GatewayServer;
16
17/// Stable content hash of a gateway server's wiring. Deterministic: the struct
18/// serialises in field order with sorted `BTreeMap`s, so the same wiring always
19/// hashes the same (provider prompt-cache friendly, #498).
20#[must_use]
21pub fn wiring_hash(server: &GatewayServer) -> String {
22    let json = serde_json::to_string(server).unwrap_or_default();
23    crate::core::hasher::hash_str(&json)
24}
25
26/// The per-addon verdict of a re-verify.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum IntegrityStatus {
29    /// Live wiring matches the pinned hash.
30    Ok,
31    /// Live wiring differs from the pinned hash (possible tampering / drift).
32    Drift,
33    /// Installed, but no live `[[gateway.servers]]` entry exists.
34    Missing,
35    /// Installed before integrity pinning — no hash recorded to check against.
36    Unpinned,
37}
38
39impl IntegrityStatus {
40    #[must_use]
41    pub fn label(&self) -> &'static str {
42        match self {
43            Self::Ok => "ok",
44            Self::Drift => "DRIFT",
45            Self::Missing => "missing",
46            Self::Unpinned => "unpinned",
47        }
48    }
49}
50
51/// One addon's re-verify result.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct IntegrityFinding {
54    pub name: String,
55    pub status: IntegrityStatus,
56}
57
58/// Re-verify every installed addon against the live gateway config. Pure over
59/// its two inputs so it is unit-testable without disk.
60#[must_use]
61pub fn verify(
62    installed: &[&super::store::InstalledAddon],
63    servers: &[GatewayServer],
64) -> Vec<IntegrityFinding> {
65    let mut out: Vec<IntegrityFinding> = installed
66        .iter()
67        .map(|addon| {
68            let live = servers.iter().find(|s| s.name == addon.gateway_server);
69            let status = match (&addon.content_hash, live) {
70                (None, _) => IntegrityStatus::Unpinned,
71                (Some(_), None) => IntegrityStatus::Missing,
72                (Some(pinned), Some(server)) => {
73                    if *pinned == wiring_hash(server) {
74                        IntegrityStatus::Ok
75                    } else {
76                        IntegrityStatus::Drift
77                    }
78                }
79            };
80            IntegrityFinding {
81                name: addon.name.clone(),
82                status,
83            }
84        })
85        .collect();
86    out.sort_by(|a, b| a.name.cmp(&b.name));
87    out
88}
89
90/// Re-verify against the on-disk store + global config.
91#[must_use]
92pub fn verify_all() -> Vec<IntegrityFinding> {
93    let store = super::store::InstalledStore::load();
94    let cfg = crate::core::config::Config::load();
95    verify(&store.list(), &cfg.gateway.servers)
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::core::addons::store::InstalledAddon;
102
103    fn server(name: &str, command: &str) -> GatewayServer {
104        GatewayServer {
105            name: name.into(),
106            command: command.into(),
107            ..Default::default()
108        }
109    }
110
111    fn installed(name: &str, hash: Option<String>) -> InstalledAddon {
112        InstalledAddon {
113            name: name.into(),
114            version: "1.0.0".into(),
115            source: "registry".into(),
116            gateway_server: name.into(),
117            granted_capabilities: None,
118            content_hash: hash,
119        }
120    }
121
122    #[test]
123    fn hash_is_deterministic_and_wiring_sensitive() {
124        let a = wiring_hash(&server("x", "cmd"));
125        let b = wiring_hash(&server("x", "cmd"));
126        assert_eq!(a, b, "same wiring → same hash");
127        let c = wiring_hash(&server("x", "other"));
128        assert_ne!(a, c, "different command → different hash");
129    }
130
131    #[test]
132    fn matching_hash_is_ok_drift_is_detected() {
133        let srv = server("demo", "demo-mcp");
134        let pinned = wiring_hash(&srv);
135        let addon = installed("demo", Some(pinned));
136
137        // Unchanged wiring → Ok.
138        let findings = verify(&[&addon], std::slice::from_ref(&srv));
139        assert_eq!(findings[0].status, IntegrityStatus::Ok);
140
141        // Tampered wiring → Drift.
142        let tampered = server("demo", "evil-mcp");
143        let findings = verify(&[&addon], &[tampered]);
144        assert_eq!(findings[0].status, IntegrityStatus::Drift);
145    }
146
147    #[test]
148    fn missing_and_unpinned_are_reported() {
149        let pinned = wiring_hash(&server("gone", "x"));
150        let missing = installed("gone", Some(pinned));
151        assert_eq!(verify(&[&missing], &[])[0].status, IntegrityStatus::Missing);
152
153        let legacy = installed("old", None);
154        assert_eq!(
155            verify(&[&legacy], &[server("old", "x")])[0].status,
156            IntegrityStatus::Unpinned
157        );
158    }
159
160    #[test]
161    fn findings_are_name_sorted() {
162        let b = installed("b", None);
163        let a = installed("a", None);
164        let findings = verify(&[&b, &a], &[]);
165        assert_eq!(findings[0].name, "a");
166        assert_eq!(findings[1].name, "b");
167    }
168}