Skip to main content

lean_ctx/core/addons/
revocation.rs

1//! Central addon revocation / kill-switch (P2).
2//!
3//! A revocation immediately **blocks an addon from running** — at three points:
4//!
5//! 1. **install** ([`super::install`]) — a revoked addon refuses to install,
6//! 2. **gateway catalog build** ([`crate::core::gateway::catalog`]) — a revoked
7//!    server is dropped from the catalog with a surfaced error (its tools
8//!    disappear), and
9//! 3. **every proxy call** ([`crate::core::gateway`]) — a call to a revoked
10//!    server is refused.
11//!
12//! This is the platform's emergency brake: a compromised or malicious addon can
13//! be neutralised without waiting for the user to uninstall it. Unlike `remove`
14//! (which the user must run), a revocation takes effect on the next gateway use.
15//!
16//! Sources (highest precedence last):
17//! 1. the **local** list `<data_dir>/addons/revocations.json`, managed by the
18//!    operator via `lean-ctx addon revoke`.
19//! 2. an **org feed** layered in through the same signed-override trust anchor as
20//!    the registry ([`super::signing`]) — verified before it can block, so a
21//!    revocation feed cannot itself be used to disable security tooling. (The
22//!    network sync that fetches the feed reuses the ctxpkg remote rails; this
23//!    module is the local enforcement core it feeds.)
24
25use std::collections::BTreeMap;
26use std::path::PathBuf;
27
28use serde::{Deserialize, Serialize};
29
30/// A single revocation entry, keyed by addon slug in [`RevocationList`].
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Revocation {
33    /// Human-readable reason, shown wherever the block surfaces.
34    pub reason: String,
35    /// When set, only this exact addon version is revoked; otherwise every
36    /// version of the slug is blocked.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub version: Option<String>,
39}
40
41/// The on-disk revocation list (`<data_dir>/addons/revocations.json`).
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct RevocationList {
44    #[serde(default)]
45    pub revocations: BTreeMap<String, Revocation>,
46}
47
48fn list_path() -> Result<PathBuf, String> {
49    Ok(crate::core::data_dir::lean_ctx_data_dir()?
50        .join("addons")
51        .join("revocations.json"))
52}
53
54impl RevocationList {
55    /// Load the list, or an empty one if it does not exist / is unreadable.
56    #[must_use]
57    pub fn load() -> Self {
58        let Ok(path) = list_path() else {
59            return Self::default();
60        };
61        match std::fs::read_to_string(&path) {
62            Ok(raw) if !raw.trim().is_empty() => serde_json::from_str(&raw).unwrap_or_default(),
63            _ => Self::default(),
64        }
65    }
66
67    /// Persist the list (creating the `addons/` dir as needed).
68    pub fn save(&self) -> Result<(), String> {
69        let path = list_path()?;
70        if let Some(parent) = path.parent() {
71            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
72        }
73        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
74        std::fs::write(&path, json).map_err(|e| e.to_string())
75    }
76
77    /// Add/replace a revocation. `version = None` blocks every version.
78    pub fn revoke(&mut self, name: &str, reason: &str, version: Option<String>) {
79        self.revocations.insert(
80            name.to_string(),
81            Revocation {
82                reason: reason.to_string(),
83                version,
84            },
85        );
86    }
87
88    /// Lift a revocation. Returns the removed entry, if any.
89    pub fn unrevoke(&mut self, name: &str) -> Option<Revocation> {
90        self.revocations.remove(name)
91    }
92
93    /// Pure verdict: is `name` (at `installed_version`, if known) revoked?
94    /// Returns the reason when blocked. A version-pinned revocation only blocks
95    /// the matching version; an unpinned one blocks regardless of version.
96    #[must_use]
97    pub fn verdict(&self, name: &str, installed_version: Option<&str>) -> Option<String> {
98        let entry = self.revocations.get(name)?;
99        match &entry.version {
100            None => Some(entry.reason.clone()),
101            Some(pinned) => match installed_version {
102                Some(v) if v == pinned => Some(entry.reason.clone()),
103                _ => None,
104            },
105        }
106    }
107}
108
109/// Runtime block check for a gateway server name: consults the local list and
110/// the installed-addon version. Returns the reason when the server must not run.
111#[must_use]
112pub fn blocked_reason(server_name: &str) -> Option<String> {
113    let installed_version = super::store::InstalledStore::load()
114        .get(server_name)
115        .map(|a| a.version.clone());
116    RevocationList::load().verdict(server_name, installed_version.as_deref())
117}
118
119/// Install-time block check: the manifest version is known directly.
120#[must_use]
121pub fn install_block(name: &str, version: &str) -> Option<String> {
122    RevocationList::load().verdict(name, Some(version))
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::core::data_dir::isolated_data_dir;
129
130    #[test]
131    fn unpinned_revocation_blocks_all_versions() {
132        let mut list = RevocationList::default();
133        list.revoke("evil", "supply-chain compromise", None);
134        assert_eq!(
135            list.verdict("evil", Some("9.9.9")).as_deref(),
136            Some("supply-chain compromise")
137        );
138        assert_eq!(
139            list.verdict("evil", None).as_deref(),
140            Some("supply-chain compromise")
141        );
142        assert!(list.verdict("clean", Some("1.0.0")).is_none());
143    }
144
145    #[test]
146    fn version_pinned_revocation_blocks_only_match() {
147        let mut list = RevocationList::default();
148        list.revoke("tool", "bad release", Some("1.2.3".into()));
149        assert!(list.verdict("tool", Some("1.2.3")).is_some());
150        assert!(list.verdict("tool", Some("1.2.4")).is_none());
151        assert!(list.verdict("tool", None).is_none());
152    }
153
154    #[test]
155    fn round_trips_through_disk_and_unrevoke() {
156        let _iso = isolated_data_dir();
157        let mut list = RevocationList::load();
158        assert!(list.revocations.is_empty());
159        list.revoke("evil", "malware", None);
160        list.save().expect("save");
161
162        let reloaded = RevocationList::load();
163        assert!(reloaded.verdict("evil", None).is_some());
164
165        let mut reloaded = reloaded;
166        assert!(reloaded.unrevoke("evil").is_some());
167        reloaded.save().expect("save");
168        assert!(RevocationList::load().verdict("evil", None).is_none());
169    }
170
171    #[test]
172    fn blocked_reason_uses_installed_version() {
173        let _iso = isolated_data_dir();
174        // Revoke a specific installed version.
175        let mut list = RevocationList::load();
176        list.revoke("demo", "pinned bad version", Some("1.0.0".into()));
177        list.save().expect("save");
178
179        let mut store = super::super::store::InstalledStore::load();
180        store.upsert(super::super::store::InstalledAddon {
181            name: "demo".into(),
182            version: "1.0.0".into(),
183            source: "registry".into(),
184            gateway_server: "demo".into(),
185            granted_capabilities: None,
186            content_hash: None,
187            install: None,
188            artifact: None,
189        });
190        store.save().expect("save");
191
192        assert!(
193            blocked_reason("demo").is_some(),
194            "installed 1.0.0 is revoked"
195        );
196        assert!(install_block("demo", "1.0.0").is_some());
197        assert!(install_block("demo", "1.0.1").is_none());
198    }
199}