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::mcp_catalog::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/// Return the reason an installed addon must not execute, if any. Drifted and
91/// missing addons fail closed when capability enforcement is enabled; legacy
92/// unpinned addons remain runnable so users can re-install them incrementally.
93#[must_use]
94pub fn execution_block(
95    addons: &super::policy::AddonsConfig,
96    installed: &[&super::store::InstalledAddon],
97    servers: &[GatewayServer],
98    gateway_server: &str,
99) -> Option<String> {
100    if !addons.enforce_capabilities {
101        return None;
102    }
103    let addon = installed
104        .iter()
105        .find(|addon| addon.gateway_server == gateway_server)?;
106    let status = verify(std::slice::from_ref(addon), servers).pop()?.status;
107    match status {
108        IntegrityStatus::Drift | IntegrityStatus::Missing => Some(format!(
109            "addon `{}` integrity check is {}",
110            addon.name,
111            status.label()
112        )),
113        IntegrityStatus::Ok | IntegrityStatus::Unpinned => None,
114    }
115}
116
117/// Runtime integrity gate for a gateway server. Loads the global-only addon
118/// policy together with live gateway wiring, just before catalog use or proxy.
119#[must_use]
120pub fn execution_block_for_server(gateway_server: &str) -> Option<String> {
121    let store = super::store::InstalledStore::load();
122    let cfg = crate::core::config::Config::load();
123    execution_block(
124        &cfg.addons,
125        &store.list(),
126        &cfg.gateway.servers,
127        gateway_server,
128    )
129}
130
131/// Re-verify against the on-disk store + global config.
132#[must_use]
133pub fn verify_all() -> Vec<IntegrityFinding> {
134    let store = super::store::InstalledStore::load();
135    let cfg = crate::core::config::Config::load();
136    verify(&store.list(), &cfg.gateway.servers)
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::core::addons::store::InstalledAddon;
143
144    fn server(name: &str, command: &str) -> GatewayServer {
145        GatewayServer {
146            name: name.into(),
147            command: command.into(),
148            ..Default::default()
149        }
150    }
151
152    fn installed(name: &str, hash: Option<String>) -> InstalledAddon {
153        InstalledAddon {
154            name: name.into(),
155            version: "1.0.0".into(),
156            source: "registry".into(),
157            gateway_server: name.into(),
158            granted_capabilities: None,
159            content_hash: hash,
160            install: None,
161            artifact: None,
162        }
163    }
164
165    #[test]
166    fn hash_is_deterministic_and_wiring_sensitive() {
167        let a = wiring_hash(&server("x", "cmd"));
168        let b = wiring_hash(&server("x", "cmd"));
169        assert_eq!(a, b, "same wiring → same hash");
170        let c = wiring_hash(&server("x", "other"));
171        assert_ne!(a, c, "different command → different hash");
172    }
173
174    #[test]
175    fn matching_hash_is_ok_drift_is_detected() {
176        let srv = server("demo", "demo-mcp");
177        let pinned = wiring_hash(&srv);
178        let addon = installed("demo", Some(pinned));
179
180        // Unchanged wiring → Ok.
181        let findings = verify(&[&addon], std::slice::from_ref(&srv));
182        assert_eq!(findings[0].status, IntegrityStatus::Ok);
183
184        // Tampered wiring → Drift.
185        let tampered = server("demo", "evil-mcp");
186        let findings = verify(&[&addon], &[tampered]);
187        assert_eq!(findings[0].status, IntegrityStatus::Drift);
188    }
189
190    #[test]
191    fn missing_and_unpinned_are_reported() {
192        let pinned = wiring_hash(&server("gone", "x"));
193        let missing = installed("gone", Some(pinned));
194        assert_eq!(verify(&[&missing], &[])[0].status, IntegrityStatus::Missing);
195
196        let legacy = installed("old", None);
197        assert_eq!(
198            verify(&[&legacy], &[server("old", "x")])[0].status,
199            IntegrityStatus::Unpinned
200        );
201    }
202
203    #[test]
204    fn drifted_addon_is_blocked_when_capabilities_are_enforced() {
205        let addon = installed("demo", Some(wiring_hash(&server("demo", "safe-mcp"))));
206        let block = execution_block(
207            &super::super::policy::AddonsConfig::default(),
208            &[&addon],
209            &[server("demo", "tampered-mcp")],
210            "demo",
211        );
212        assert!(block.is_some_and(|reason| reason.contains("DRIFT")));
213    }
214
215    #[test]
216    fn missing_addon_is_blocked_when_capabilities_are_enforced() {
217        let addon = installed("gone", Some(wiring_hash(&server("gone", "gone-mcp"))));
218        let block = execution_block(
219            &super::super::policy::AddonsConfig::default(),
220            &[&addon],
221            &[],
222            "gone",
223        );
224        assert!(block.is_some_and(|reason| reason.contains("missing")));
225    }
226
227    #[test]
228    fn unpinned_addon_is_warned_but_allowed() {
229        let addon = installed("legacy", None);
230        let servers = [server("legacy", "legacy-mcp")];
231        assert_eq!(
232            verify(&[&addon], &servers)[0].status,
233            IntegrityStatus::Unpinned
234        );
235        assert_eq!(
236            execution_block(
237                &super::super::policy::AddonsConfig::default(),
238                &[&addon],
239                &servers,
240                "legacy",
241            ),
242            None
243        );
244    }
245
246    #[test]
247    fn findings_are_name_sorted() {
248        let b = installed("b", None);
249        let a = installed("a", None);
250        let findings = verify(&[&b, &a], &[]);
251        assert_eq!(findings[0].name, "a");
252        assert_eq!(findings[1].name, "b");
253    }
254}