Skip to main content

lean_ctx/core/addons/
binhash.rs

1//! Binary-hash pinning for stdio addons (P3 — supply-chain hardening).
2//!
3//! A stdio addon spawns a local executable. Pinning that binary's SHA-256 in
4//! the manifest (`[mcp] sha256 = "…"`) closes the gap between *what was audited*
5//! and *what actually runs*: if the file on `PATH` is swapped after install, the
6//! hash no longer matches and the gateway refuses to spawn it.
7//!
8//! SHA-256 (not the engine's internal BLAKE3) is deliberate — an author pins the
9//! value an ordinary `sha256sum my-mcp` / `shasum -a 256 my-mcp` prints, so the
10//! pin is reproducible without lean-ctx.
11
12use std::io::Read;
13use std::path::{Path, PathBuf};
14
15use sha2::{Digest, Sha256};
16
17/// Stream-hash a file and return its lowercase hex SHA-256. Streaming (8 KiB
18/// chunks) keeps memory flat regardless of binary size.
19pub fn sha256_file(path: &Path) -> Result<String, String> {
20    let mut file =
21        std::fs::File::open(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
22    let mut hasher = Sha256::new();
23    let mut buf = [0u8; 8192];
24    loop {
25        let n = file
26            .read(&mut buf)
27            .map_err(|e| format!("read {} failed: {e}", path.display()))?;
28        if n == 0 {
29            break;
30        }
31        hasher.update(&buf[..n]);
32    }
33    Ok(crate::core::agent_identity::hex_encode(&hasher.finalize()))
34}
35
36/// Resolve a stdio `command` to a concrete file path. An absolute/relative path
37/// (anything containing a separator) is used as-is; a bare name is looked up on
38/// `PATH`, honouring `PATHEXT`-free Unix semantics (first executable match).
39#[must_use]
40pub fn resolve_on_path(command: &str) -> Option<PathBuf> {
41    let cmd = command.trim();
42    if cmd.is_empty() {
43        return None;
44    }
45    if cmd.contains('/') || cmd.contains('\\') {
46        let p = PathBuf::from(cmd);
47        return p.is_file().then_some(p);
48    }
49    let path_var = std::env::var_os("PATH")?;
50    std::env::split_paths(&path_var)
51        .map(|dir| dir.join(cmd))
52        .find(|candidate| candidate.is_file())
53}
54
55/// Verify that `command` resolves to a binary whose SHA-256 equals
56/// `expected_sha256`. An empty `expected_sha256` means "no pin" → `Ok`. The
57/// comparison is case-insensitive over hex; any mismatch, unresolved binary, or
58/// read error is a hard failure (fail-closed — a pin you cannot check is a pin
59/// that failed).
60pub fn verify_binary(command: &str, expected_sha256: &str) -> Result<(), String> {
61    let expected = expected_sha256.trim();
62    if expected.is_empty() {
63        return Ok(());
64    }
65    let path = resolve_on_path(command).ok_or_else(|| {
66        format!("binary `{command}` is pinned (sha256) but could not be found on PATH")
67    })?;
68    let actual = sha256_file(&path)?;
69    if actual.eq_ignore_ascii_case(expected) {
70        Ok(())
71    } else {
72        Err(format!(
73            "binary `{command}` failed its sha256 pin — expected {}…, got {}… ({} may have been \
74             replaced)",
75            short(expected),
76            short(&actual),
77            path.display()
78        ))
79    }
80}
81
82fn short(hex: &str) -> String {
83    hex.chars().take(12).collect()
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    /// A unique scratch dir per test (PID + label) so parallel tests never share
91    /// — and clean up — the same directory.
92    fn tmp(label: &str) -> PathBuf {
93        std::env::temp_dir().join(format!("leanctx-binhash-{}-{label}", std::process::id()))
94    }
95
96    #[test]
97    fn hashes_file_matching_known_sha256() {
98        let dir = tmp("known");
99        std::fs::create_dir_all(&dir).unwrap();
100        let f = dir.join("payload.bin");
101        std::fs::write(&f, b"abc").unwrap();
102        // Known SHA-256 of "abc".
103        let expected = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
104        assert_eq!(sha256_file(&f).unwrap(), expected);
105        std::fs::remove_dir_all(&dir).ok();
106    }
107
108    #[test]
109    fn empty_pin_is_ok_without_resolving() {
110        // No pin → never even touches the filesystem.
111        assert!(verify_binary("definitely-not-a-real-binary-xyz", "").is_ok());
112    }
113
114    #[test]
115    fn verify_matches_and_detects_mismatch() {
116        let dir = tmp("verify");
117        std::fs::create_dir_all(&dir).unwrap();
118        let f = dir.join("my-mcp");
119        std::fs::write(&f, b"hello world").unwrap();
120        let good = sha256_file(&f).unwrap();
121        let path_str = f.to_string_lossy().to_string();
122
123        assert!(verify_binary(&path_str, &good).is_ok());
124        assert!(verify_binary(&path_str, &good.to_uppercase()).is_ok());
125        assert!(
126            verify_binary(&path_str, &"0".repeat(64)).is_err(),
127            "wrong hash must fail"
128        );
129        std::fs::remove_dir_all(&dir).ok();
130    }
131
132    #[test]
133    fn pinned_but_missing_binary_fails_closed() {
134        let err = verify_binary(
135            "/nonexistent/path/to/mcp",
136            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
137        );
138        assert!(err.is_err(), "a pin that cannot be checked must fail");
139    }
140
141    #[test]
142    fn resolve_absolute_path_roundtrips() {
143        let dir = tmp("resolve");
144        std::fs::create_dir_all(&dir).unwrap();
145        let f = dir.join("tool");
146        std::fs::write(&f, b"x").unwrap();
147        let resolved = resolve_on_path(&f.to_string_lossy()).expect("absolute path resolves");
148        assert_eq!(resolved, f);
149        std::fs::remove_dir_all(&dir).ok();
150    }
151}