Skip to main content

oxicode/store/
hook_approval.rs

1//! First-run approval gate for project-scoped `[[hooks]]`.
2//!
3//! Project `.oxicode/settings.toml` may contain hooks that execute
4//! arbitrary shell commands. To prevent supply-chain attacks via a
5//! cloned repo, the cli requires the user to approve the project's
6//! hook list once. Approval is cached in
7//! `~/.oxicode/hooks_approved.toml` keyed by repo path + a hash of
8//! the project settings file. If the settings file changes, the hash
9//! mismatches and the user is re-prompted.
10//!
11//! The `oxicode-sdk` has no concept of "approved" — this gate is
12//! purely a product-layer policy.
13
14use std::collections::HashMap;
15use std::io::{self, Write};
16use std::path::{Path, PathBuf};
17
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21
22const APPROVAL_FILENAME: &str = "hooks_approved.toml";
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct HookApprovalEntry {
26    /// SHA-256 of the project settings file (hex).
27    pub settings_hash: String,
28    /// When the user approved this combination.
29    pub approved_at: DateTime<Utc>,
30}
31
32#[derive(Debug, Default, Clone, Serialize, Deserialize)]
33struct ApprovalFile {
34    /// repo abs path → approval record.
35    #[serde(default)]
36    entries: HashMap<String, HookApprovalEntry>,
37}
38
39pub struct HookApprovalRegistry {
40    path: PathBuf,
41    entries: HashMap<String, HookApprovalEntry>,
42}
43
44impl std::fmt::Debug for HookApprovalRegistry {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct("HookApprovalRegistry")
47            .field("path", &self.path)
48            .field("entry_count", &self.entries.len())
49            .finish()
50    }
51}
52
53impl HookApprovalRegistry {
54    /// Load from `~/.oxicode/hooks_approved.toml`. If the file does not
55    /// exist or is corrupt, return an empty registry.
56    pub fn load_or_default() -> Self {
57        let path = match default_approval_path() {
58            Ok(p) => p,
59            Err(_) => return Self::empty(),
60        };
61        let entries = std::fs::read_to_string(&path)
62            .ok()
63            .and_then(|s| toml::from_str::<ApprovalFile>(&s).ok())
64            .map(|f| f.entries)
65            .unwrap_or_default();
66        Self { path, entries }
67    }
68
69    fn empty() -> Self {
70        Self {
71            path: PathBuf::new(),
72            entries: HashMap::new(),
73        }
74    }
75
76    /// Returns true if the given repo path + settings hash is currently
77    /// approved.
78    pub fn is_approved(&self, repo_path: &Path, settings_hash: &str) -> bool {
79        self.entries
80            .get(&canonical_key(repo_path))
81            .is_some_and(|e| e.settings_hash == settings_hash)
82    }
83
84    /// Record approval for the given repo + settings hash. Caller must
85    /// `persist()` afterwards.
86    pub fn approve(&mut self, repo_path: &Path, settings_hash: &str) {
87        self.entries.insert(
88            canonical_key(repo_path),
89            HookApprovalEntry {
90                settings_hash: settings_hash.to_string(),
91                approved_at: Utc::now(),
92            },
93        );
94    }
95
96    /// Atomically write the approval file to disk. Creates the parent
97    /// directory if needed.
98    pub fn persist(&self) -> io::Result<()> {
99        if self.path.as_os_str().is_empty() {
100            return Ok(());
101        }
102        if let Some(parent) = self.path.parent() {
103            std::fs::create_dir_all(parent)?;
104        }
105        let file = ApprovalFile {
106            entries: self.entries.clone(),
107        };
108        let body = toml::to_string_pretty(&file).map_err(io::Error::other)?;
109        let tmp = self.path.with_extension("toml.tmp");
110        std::fs::write(&tmp, body)?;
111        std::fs::rename(&tmp, &self.path)?;
112        Ok(())
113    }
114}
115
116/// SHA-256 (hex) of the project settings file content.
117pub fn hash_settings(content: &str) -> String {
118    let mut h = Sha256::new();
119    h.update(content.as_bytes());
120    let digest = h.finalize();
121    let mut out = String::with_capacity(64);
122    for b in digest {
123        use std::fmt::Write as _;
124        let _ = write!(&mut out, "{:02x}", b);
125    }
126    out
127}
128
129fn default_approval_path() -> io::Result<PathBuf> {
130    let home = dirs::home_dir()
131        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home dir not found"))?;
132    Ok(home.join(".oxicode").join(APPROVAL_FILENAME))
133}
134
135fn canonical_key(p: &Path) -> String {
136    std::fs::canonicalize(p)
137        .unwrap_or_else(|_| p.to_path_buf())
138        .to_string_lossy()
139        .into_owned()
140}
141
142/// Read a Y/n line from stdin. Defaults to `false` (deny) on EOF or
143/// parse error. This matches Claude Code's behavior of erring on the
144/// safe side.
145pub fn prompt_for_approval(repo_path: &Path, hook_count: usize) -> bool {
146    eprintln!();
147    eprintln!(
148        "Project at {} wants to run {} hook(s) defined in `.oxicode/settings.toml`.",
149        repo_path.display(),
150        hook_count
151    );
152    eprintln!("Allow? [y/N]");
153    eprint!("> ");
154    let _ = io::stderr().flush();
155    let mut line = String::new();
156    if io::stdin().read_line(&mut line).is_err() {
157        return false;
158    }
159    matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use tempfile::TempDir;
166
167    #[test]
168    fn hash_is_deterministic_and_hex() {
169        let h1 = hash_settings("hello");
170        let h2 = hash_settings("hello");
171        assert_eq!(h1, h2);
172        assert_eq!(h1.len(), 64);
173        assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
174    }
175
176    #[test]
177    fn empty_registry_approves_nothing() {
178        let r = HookApprovalRegistry::load_or_default();
179        assert!(!r.is_approved(Path::new("/tmp/nope"), "abc"));
180    }
181
182    #[test]
183    fn approve_then_check_round_trip() {
184        let tmp = TempDir::new().unwrap();
185        let p = tmp.path().join("hooks_approved.toml");
186        let mut r = HookApprovalRegistry {
187            path: p.clone(),
188            entries: HashMap::new(),
189        };
190        let repo = tmp.path().join("repo");
191        std::fs::create_dir_all(&repo).unwrap();
192        r.approve(&repo, "deadbeef");
193        r.persist().unwrap();
194        assert!(p.exists());
195
196        // Re-load from disk.
197        let text = std::fs::read_to_string(&p).unwrap();
198        let file: ApprovalFile = toml::from_str(&text).unwrap();
199        let r2 = HookApprovalRegistry {
200            path: p,
201            entries: file.entries,
202        };
203        assert!(r2.is_approved(&repo, "deadbeef"));
204        assert!(!r2.is_approved(&repo, "f0000000"));
205    }
206
207    #[test]
208    fn hash_mismatch_revokes_approval() {
209        let tmp = TempDir::new().unwrap();
210        let repo = tmp.path().join("r");
211        std::fs::create_dir_all(&repo).unwrap();
212        let mut r = HookApprovalRegistry {
213            path: tmp.path().join("f.toml"),
214            entries: HashMap::new(),
215        };
216        r.approve(&repo, "v1");
217        assert!(r.is_approved(&repo, "v1"));
218        // Settings changed → new hash → no longer approved.
219        assert!(!r.is_approved(&repo, "v2"));
220    }
221}