Skip to main content

mur_common/trust/
skills.rs

1use crate::skill::ct_eq_hex;
2use crate::skill::types::TrustLevel;
3use fs2::FileExt;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6use std::fs;
7use std::io::{self, Write};
8use std::path::{Path, PathBuf};
9
10/// Current on-disk schema. Bump only for a change that needs a migration.
11///
12/// - **1** — hash keys written by `content_sha256` (plain canonical YAML).
13/// - **2** — hash keys written by `content_hash_for_trust` (canonical YAML with
14///   `transfer_chain` / `evolution_log` excluded), so a transfer or a
15///   generation increment no longer re-keys an entry out from under the loader.
16pub const TRUST_STORE_SCHEMA: u32 = 2;
17
18#[derive(Debug, Serialize, Deserialize)]
19pub struct SkillTrustStore {
20    /// On-disk schema version. Absent in stores written before the field
21    /// existed, which are exactly the v1 stores — hence `default = 1`.
22    #[serde(default = "schema_v1")]
23    pub schema: u32,
24
25    pub entries: BTreeMap<String, TrustEntry>,
26
27    /// Kill-switch — content hashes that may NEVER load, regardless of
28    /// the per-entry trust level.
29    #[serde(default)]
30    pub revoked: Vec<String>,
31}
32
33fn schema_v1() -> u32 {
34    1
35}
36
37impl Default for SkillTrustStore {
38    /// A store created in memory is already current — only a store read from
39    /// disk can be older, and `serde` supplies 1 for those.
40    fn default() -> Self {
41        Self {
42            schema: TRUST_STORE_SCHEMA,
43            entries: BTreeMap::new(),
44            revoked: Vec::new(),
45        }
46    }
47}
48
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct TrustEntry {
51    pub name: String,
52    pub version: String,
53    pub level: TrustLevel,
54    pub installed_at: String,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub publisher: Option<String>,
57    /// SHA-256 hex of the installed skill YAML; used for rug-pull detection.
58    #[serde(default)]
59    pub content_sha256: String,
60    /// Key fingerprint of the signer at install time; used for publisher-change detection.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub signer_key_fp: Option<String>,
63}
64
65#[derive(Debug)]
66pub enum TrustStoreError {
67    Io(io::Error),
68    Parse(serde_json::Error),
69}
70
71impl std::fmt::Display for TrustStoreError {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            TrustStoreError::Io(e) => write!(f, "io: {e}"),
75            TrustStoreError::Parse(e) => write!(f, "parse: {e}"),
76        }
77    }
78}
79
80impl std::error::Error for TrustStoreError {}
81
82impl From<io::Error> for TrustStoreError {
83    fn from(e: io::Error) -> Self {
84        TrustStoreError::Io(e)
85    }
86}
87
88impl From<serde_json::Error> for TrustStoreError {
89    fn from(e: serde_json::Error) -> Self {
90        TrustStoreError::Parse(e)
91    }
92}
93
94impl SkillTrustStore {
95    pub fn path(mur_home: &Path) -> PathBuf {
96        mur_home.join("trust").join("skills.json")
97    }
98
99    pub fn load(mur_home: &Path) -> Result<Self, TrustStoreError> {
100        let p = Self::path(mur_home);
101        if !p.exists() {
102            return Ok(Self::default());
103        }
104        let s = fs::read_to_string(&p)?;
105        if s.trim().is_empty() {
106            return Ok(Self::default());
107        }
108        Ok(serde_json::from_str(&s)?)
109    }
110
111    pub fn save(&self, mur_home: &Path) -> Result<(), TrustStoreError> {
112        let dir = mur_home.join("trust");
113        fs::create_dir_all(&dir)?;
114        let lock_path = dir.join(".skills.lock");
115        let lock = fs::OpenOptions::new()
116            .read(true)
117            .write(true)
118            .create(true)
119            .truncate(false)
120            .open(&lock_path)?;
121        lock.lock_exclusive()?;
122
123        let result = (|| -> Result<(), TrustStoreError> {
124            let final_path = Self::path(mur_home);
125            let tmp = dir.join(".skills.json.tmp");
126            let json = serde_json::to_string_pretty(self)?;
127            {
128                let mut f = fs::File::create(&tmp)?;
129                f.write_all(json.as_bytes())?;
130                f.sync_all()?;
131            }
132            #[cfg(unix)]
133            {
134                use std::os::unix::fs::PermissionsExt;
135                fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?;
136            }
137            fs::rename(&tmp, &final_path)?;
138            Ok(())
139        })();
140
141        let _ = FileExt::unlock(&lock);
142        let _ = lock;
143        result
144    }
145
146    pub fn insert(&mut self, hash: String, entry: TrustEntry) {
147        self.entries.insert(hash, entry);
148    }
149
150    pub fn lookup(&self, hash: &str) -> Option<&TrustEntry> {
151        if self.is_revoked(hash) {
152            return None;
153        }
154        for (k, v) in &self.entries {
155            if ct_eq_hex(k, hash) {
156                return Some(v);
157            }
158        }
159        None
160    }
161
162    /// Re-key v1 hash-keyed entries into the trust hash domain (schema 1 → 2).
163    ///
164    /// v1 keyed by `content_sha256`; the loader now looks up
165    /// `content_hash_for_trust`. Without this every already-installed skill
166    /// misses its entry and silently drops to `Sandboxed` — fail-closed, so no
167    /// privilege is gained, but every recorded trust level would be lost.
168    ///
169    /// Re-keying needs the manifest, which the store does not hold, so each
170    /// entry is recomputed from the skill still on disk. What that implies:
171    ///
172    /// - **Name-keyed entries are left alone.** `registry-add` keys by skill
173    ///   name on purpose (the drift baseline). Only 64-hex keys are candidates.
174    /// - **An entry whose skill is no longer installed is kept as-is.** It
175    ///   cannot be recomputed, and dropping it would silently discard a
176    ///   `Trusted` decision the user made. A stale key is inert; a deleted one
177    ///   is not recoverable.
178    /// - **Already-correct keys are cheap no-ops** — the recomputed hash equals
179    ///   the existing key and the entry is reinserted unchanged.
180    ///
181    /// Returns `None` if the store was already current, or `Some(n)` with the
182    /// number of entries re-keyed. `Some(0)` still means the schema was bumped
183    /// and the store must be saved — otherwise a store with nothing to move
184    /// never records that it migrated and repeats the work on every start.
185    pub fn migrate_to_trust_hash<F>(&mut self, load_manifest: F) -> Option<usize>
186    where
187        F: Fn(&str) -> Option<crate::skill::SkillManifest>,
188    {
189        if self.schema >= TRUST_STORE_SCHEMA {
190            return None;
191        }
192        let is_hash_key = |k: &str| k.len() == 64 && k.chars().all(|c| c.is_ascii_hexdigit());
193
194        let mut rekeyed = 0usize;
195        let mut moved: Vec<(String, String)> = Vec::new();
196        for key in self.entries.keys() {
197            if !is_hash_key(key) {
198                continue; // name-keyed drift baseline — deliberately not a hash
199            }
200            let Some(entry) = self.entries.get(key) else {
201                continue;
202            };
203            let Some(manifest) = load_manifest(&entry.name) else {
204                continue; // skill gone from disk; keep the entry rather than lose it
205            };
206            let Ok(new_key) = crate::skill::content_hash_for_trust(&manifest) else {
207                continue;
208            };
209            if new_key != *key {
210                moved.push((key.clone(), new_key));
211            }
212        }
213        for (old, new) in moved {
214            if let Some(entry) = self.entries.remove(&old) {
215                self.entries.insert(new, entry);
216                rekeyed += 1;
217            }
218        }
219        self.schema = TRUST_STORE_SCHEMA;
220        Some(rekeyed)
221    }
222
223    pub fn is_revoked(&self, hash: &str) -> bool {
224        self.revoked.iter().any(|r| ct_eq_hex(r, hash))
225    }
226
227    pub fn revoke(&mut self, hash: &str) {
228        if !self.is_revoked(hash) {
229            self.revoked.push(hash.to_string());
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use tempfile::tempdir;
238
239    fn entry() -> TrustEntry {
240        TrustEntry {
241            name: "demo".into(),
242            version: "1.0.0".into(),
243            level: TrustLevel::Verified,
244            installed_at: "2026-05-24T00:00:00Z".into(),
245            publisher: Some("human:t".into()),
246            ..Default::default()
247        }
248    }
249
250    fn manifest(name: &str, evolved: bool) -> crate::skill::SkillManifest {
251        let yaml = format!(
252            "name: {name}\nversion: 1.0.0\npublisher: human:t\ndescription: d\ncategory: context\ncontent:\n  abstract: a\n"
253        );
254        let mut m = crate::skill::parse_canonical(&yaml).unwrap();
255        if evolved {
256            m.evolution_log
257                .push(crate::skill::evolution::EvolutionEvent::initial_human(
258                    "t", "1.0.0",
259                ));
260        }
261        m
262    }
263
264    /// A store written before the schema field existed reads as v1 and migrates.
265    #[test]
266    fn a_store_without_a_schema_field_is_v1() {
267        let s: SkillTrustStore = serde_json::from_str(r#"{"entries":{},"revoked":[]}"#).unwrap();
268        assert_eq!(s.schema, 1);
269        // ...while one built in memory is already current.
270        assert_eq!(SkillTrustStore::default().schema, TRUST_STORE_SCHEMA);
271    }
272
273    /// Name-keyed entries are the drift baseline and must survive untouched —
274    /// re-keying them to a hash would destroy the only record that spans
275    /// versions.
276    #[test]
277    fn migration_leaves_name_keyed_entries_alone() {
278        let m = manifest("demo", true);
279        let mut s = SkillTrustStore {
280            schema: 1,
281            ..Default::default()
282        };
283        s.entries.insert("demo".into(), entry());
284
285        let moved = s.migrate_to_trust_hash(|_| Some(m.clone()));
286
287        assert_eq!(moved, Some(0), "a name key is not a hash key");
288        assert!(s.entries.contains_key("demo"));
289        assert_eq!(s.schema, TRUST_STORE_SCHEMA);
290    }
291
292    /// An entry whose skill is gone cannot be recomputed. Keep it: a stale key
293    /// is inert, but discarding it would silently drop a trust decision.
294    #[test]
295    fn migration_keeps_an_entry_whose_skill_is_no_longer_installed() {
296        let legacy = "a".repeat(64);
297        let mut s = SkillTrustStore {
298            schema: 1,
299            ..Default::default()
300        };
301        s.entries.insert(legacy.clone(), entry());
302
303        let moved = s.migrate_to_trust_hash(|_| None);
304
305        assert_eq!(moved, Some(0));
306        assert!(s.entries.contains_key(&legacy), "trust decision was lost");
307    }
308
309    /// The re-key itself, end to end, and the entry keeps its level.
310    #[test]
311    fn migration_rekeys_a_hash_entry_into_the_trust_domain() {
312        let m = manifest("demo", true);
313        let legacy = crate::skill::content_sha256(&m).unwrap();
314        let target = crate::skill::content_hash_for_trust(&m).unwrap();
315        assert_ne!(legacy, target, "precondition: domains must differ");
316
317        let mut s = SkillTrustStore {
318            schema: 1,
319            ..Default::default()
320        };
321        s.entries.insert(legacy.clone(), entry());
322
323        let moved = s.migrate_to_trust_hash(|_| Some(m.clone()));
324
325        assert_eq!(moved, Some(1));
326        assert!(!s.entries.contains_key(&legacy));
327        assert_eq!(s.entries.get(&target).unwrap().level, TrustLevel::Verified);
328    }
329
330    /// Idempotent: a current store is left completely alone, and reports so.
331    #[test]
332    fn migration_is_a_no_op_on_a_current_store() {
333        let m = manifest("demo", true);
334        let mut s = SkillTrustStore::default();
335        let legacy = crate::skill::content_sha256(&m).unwrap();
336        s.entries.insert(legacy.clone(), entry());
337
338        assert_eq!(s.migrate_to_trust_hash(|_| Some(m.clone())), None);
339        assert!(
340            s.entries.contains_key(&legacy),
341            "a v2 store must not be re-keyed again"
342        );
343    }
344
345    #[test]
346    fn insert_lookup_save_load_roundtrip() {
347        let dir = tempdir().unwrap();
348        let mut s = SkillTrustStore::default();
349        s.insert("a".repeat(64), entry());
350        s.save(dir.path()).unwrap();
351        let s2 = SkillTrustStore::load(dir.path()).unwrap();
352        assert_eq!(s2.entries.len(), 1);
353        assert_eq!(s2.lookup(&"a".repeat(64)).unwrap().name, "demo");
354    }
355
356    #[test]
357    fn revoked_hash_returns_none() {
358        let mut s = SkillTrustStore::default();
359        let h = "b".repeat(64);
360        s.insert(h.clone(), entry());
361        s.revoke(&h);
362        assert!(s.lookup(&h).is_none());
363        assert!(s.is_revoked(&h));
364    }
365
366    #[test]
367    fn missing_file_loads_empty() {
368        let dir = tempdir().unwrap();
369        let s = SkillTrustStore::load(dir.path()).unwrap();
370        assert!(s.entries.is_empty());
371    }
372
373    #[cfg(unix)]
374    #[test]
375    fn saved_file_is_0600() {
376        use std::os::unix::fs::PermissionsExt;
377        let dir = tempdir().unwrap();
378        let s = SkillTrustStore::default();
379        s.save(dir.path()).unwrap();
380        let mode = fs::metadata(SkillTrustStore::path(dir.path()))
381            .unwrap()
382            .permissions()
383            .mode()
384            & 0o777;
385        assert_eq!(mode, 0o600);
386    }
387
388    #[test]
389    fn revoke_is_idempotent() {
390        let mut s = SkillTrustStore::default();
391        s.revoke("c".repeat(64).as_str());
392        s.revoke("c".repeat(64).as_str());
393        assert_eq!(s.revoked.len(), 1);
394    }
395}