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 the canonical hooks-approval file, falling back read-only
55    /// to the legacy `~/.oxicode/<file>` when the canonical file is absent.
56    /// If neither exists or is corrupt, return an empty registry. Persistence
57    /// always targets the canonical path.
58    pub fn load_or_default() -> Self {
59        let Some(path) = default_approval_path().ok() else {
60            return Self::empty();
61        };
62        let entries = approval_read_path()
63            .ok()
64            .filter(|p| p.exists())
65            .and_then(|p| std::fs::read_to_string(p).ok())
66            .and_then(|s| toml::from_str::<ApprovalFile>(&s).ok())
67            .map(|f| f.entries)
68            .unwrap_or_default();
69        Self { path, entries }
70    }
71
72    fn empty() -> Self {
73        Self {
74            path: PathBuf::new(),
75            entries: HashMap::new(),
76        }
77    }
78
79    /// Returns true if the given repo path + settings hash is currently
80    /// approved.
81    pub fn is_approved(&self, repo_path: &Path, settings_hash: &str) -> bool {
82        self.entries
83            .get(&canonical_key(repo_path))
84            .is_some_and(|e| e.settings_hash == settings_hash)
85    }
86
87    /// Record approval for the given repo + settings hash. Caller must
88    /// `persist()` afterwards.
89    pub fn approve(&mut self, repo_path: &Path, settings_hash: &str) {
90        self.entries.insert(
91            canonical_key(repo_path),
92            HookApprovalEntry {
93                settings_hash: settings_hash.to_string(),
94                approved_at: Utc::now(),
95            },
96        );
97    }
98
99    /// Atomically write the approval file to disk. Creates the parent
100    /// directory if needed.
101    pub fn persist(&self) -> io::Result<()> {
102        if self.path.as_os_str().is_empty() {
103            return Ok(());
104        }
105        if let Some(parent) = self.path.parent() {
106            std::fs::create_dir_all(parent)?;
107        }
108        let file = ApprovalFile {
109            entries: self.entries.clone(),
110        };
111        let body = toml::to_string_pretty(&file).map_err(io::Error::other)?;
112        let tmp = self.path.with_extension("toml.tmp");
113        std::fs::write(&tmp, body)?;
114        std::fs::rename(&tmp, &self.path)?;
115        Ok(())
116    }
117}
118
119/// SHA-256 (hex) of the project settings file content.
120pub fn hash_settings(content: &str) -> String {
121    let mut h = Sha256::new();
122    h.update(content.as_bytes());
123    let digest = h.finalize();
124    let mut out = String::with_capacity(64);
125    for b in digest {
126        use std::fmt::Write as _;
127        let _ = write!(&mut out, "{:02x}", b);
128    }
129    out
130}
131
132fn default_approval_path() -> io::Result<PathBuf> {
133    oxicode_catalog::oxi_home::oxicode_home()
134        .map(|h| h.join(APPROVAL_FILENAME))
135        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "oxicode home not found"))
136}
137
138/// Read path for the approval file: canonical when it exists, else the
139/// legacy `~/.oxicode/<file>` when present.
140fn approval_read_path() -> io::Result<PathBuf> {
141    oxicode_catalog::oxi_home::read_path(Path::new(APPROVAL_FILENAME))
142        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "oxicode home not found"))
143}
144
145fn canonical_key(p: &Path) -> String {
146    std::fs::canonicalize(p)
147        .unwrap_or_else(|_| p.to_path_buf())
148        .to_string_lossy()
149        .into_owned()
150}
151
152/// Read a Y/n line from stdin. Defaults to `false` (deny) on EOF or
153/// parse error. This matches Claude Code's behavior of erring on the
154/// safe side.
155pub fn prompt_for_approval(repo_path: &Path, hook_count: usize) -> bool {
156    eprintln!();
157    eprintln!(
158        "Project at {} wants to run {} hook(s) defined in `.oxicode/settings.toml`.",
159        repo_path.display(),
160        hook_count
161    );
162    eprintln!("Allow? [y/N]");
163    eprint!("> ");
164    let _ = io::stderr().flush();
165    let mut line = String::new();
166    if io::stdin().read_line(&mut line).is_err() {
167        return false;
168    }
169    matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use tempfile::TempDir;
176
177    #[test]
178    fn hash_is_deterministic_and_hex() {
179        let h1 = hash_settings("hello");
180        let h2 = hash_settings("hello");
181        assert_eq!(h1, h2);
182        assert_eq!(h1.len(), 64);
183        assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
184    }
185
186    #[test]
187    fn empty_registry_approves_nothing() {
188        let r = HookApprovalRegistry::load_or_default();
189        assert!(!r.is_approved(Path::new("/tmp/nope"), "abc"));
190    }
191
192    #[test]
193    fn approve_then_check_round_trip() {
194        let tmp = TempDir::new().unwrap();
195        let p = tmp.path().join("hooks_approved.toml");
196        let mut r = HookApprovalRegistry {
197            path: p.clone(),
198            entries: HashMap::new(),
199        };
200        let repo = tmp.path().join("repo");
201        std::fs::create_dir_all(&repo).unwrap();
202        r.approve(&repo, "deadbeef");
203        r.persist().unwrap();
204        assert!(p.exists());
205
206        // Re-load from disk.
207        let text = std::fs::read_to_string(&p).unwrap();
208        let file: ApprovalFile = toml::from_str(&text).unwrap();
209        let r2 = HookApprovalRegistry {
210            path: p,
211            entries: file.entries,
212        };
213        assert!(r2.is_approved(&repo, "deadbeef"));
214        assert!(!r2.is_approved(&repo, "f0000000"));
215    }
216
217    #[test]
218    fn hash_mismatch_revokes_approval() {
219        let tmp = TempDir::new().unwrap();
220        let repo = tmp.path().join("r");
221        std::fs::create_dir_all(&repo).unwrap();
222        let mut r = HookApprovalRegistry {
223            path: tmp.path().join("f.toml"),
224            entries: HashMap::new(),
225        };
226        r.approve(&repo, "v1");
227        assert!(r.is_approved(&repo, "v1"));
228        // Settings changed → new hash → no longer approved.
229        assert!(!r.is_approved(&repo, "v2"));
230    }
231}