Skip to main content

agentsec_core/installer/
mod.rs

1//! Installer / uninstaller for AgentSec.
2//!
3//! Patches `~/.claude.json` (MCP entry) and `~/.claude/settings.json` (hooks).
4//! All public functions accept [`Paths`] so tests can redirect paths via the
5//! standard env-override mechanism.
6//!
7//! ## Dry-run default
8//!
9//! Both [`install`] and [`uninstall`] accept a `plan` with an `apply: bool`
10//! field. When `apply` is `false` (the default) the functions compute what
11//! *would* change and return a report, but no file is written and no backup
12//! is created. Real writes only happen when `apply` is `true`.
13
14use std::path::PathBuf;
15
16use serde_json::Value;
17
18use crate::config::Paths;
19use crate::error::{Error, Result};
20
21pub(crate) mod backup;
22pub(crate) mod hooks;
23pub(crate) mod mcp;
24
25// ── public types ──────────────────────────────────────────────────────────────
26
27/// Scope for the MCP entry and hook install.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum InstallScope {
30    /// User-level scope: `~/.claude.json:mcpServers.agentsec`.
31    User,
32    /// Project-level scope: `~/.claude.json:projects.<cwd>.mcpServers.agentsec`.
33    Project,
34}
35
36/// Parameters for an install operation.
37#[derive(Debug, Clone)]
38pub struct InstallPlan {
39    /// Where to register the MCP entry.
40    pub scope: InstallScope,
41    /// Optional path to a `.env` file passed as `AGENTSEC_DOTENV` env var in
42    /// the MCP entry.
43    pub dotenv: Option<PathBuf>,
44    /// Whether to register the hook entries in `~/.claude/settings.json`.
45    pub with_hooks: bool,
46    /// Overwrite existing entries even if they already exist.
47    pub force: bool,
48    /// If `false` (default), compute changes but do not write any files.
49    /// Set `true` to apply mutations.
50    pub apply: bool,
51}
52
53/// Parameters for an uninstall operation.
54#[derive(Debug, Clone)]
55pub struct UninstallPlan {
56    /// Which scope's MCP entry to remove.
57    pub scope: InstallScope,
58    /// If `true`, leave the MCP entry in `~/.claude.json` intact.
59    pub keep_mcp: bool,
60    /// If `true`, leave the hook entries in `~/.claude/settings.json` intact.
61    pub keep_hooks: bool,
62    /// If `false` (default), compute changes but do not write any files.
63    pub apply: bool,
64}
65
66/// Kind of change produced by an install/uninstall operation.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum ChangeKind {
69    /// The entry was created fresh.
70    Added,
71    /// An existing entry was replaced (force mode).
72    Updated,
73    /// The entry was deleted.
74    Removed,
75    /// No change was needed (idempotent).
76    NoOp,
77}
78
79/// A single file-level mutation.
80#[derive(Debug, Clone)]
81pub struct MutationChange {
82    /// The file that was (or would be) modified.
83    pub target: PathBuf,
84    /// Kind of change.
85    pub kind: ChangeKind,
86    /// Human-readable description.
87    pub description: String,
88}
89
90/// Report returned by [`install`] or [`uninstall`].
91#[derive(Debug, Clone)]
92pub struct InstallReport {
93    /// Change to `~/.claude.json` (MCP entry), if any.
94    pub mcp_change: Option<MutationChange>,
95    /// Changes to `~/.claude/settings.json` (hooks), one per hook event.
96    pub hooks_changes: Vec<MutationChange>,
97    /// Paths of backup files created (`apply` mode only; dry-run leaves this
98    /// empty).
99    pub backups: Vec<PathBuf>,
100    /// Whether the plan was applied (`apply = true`).
101    pub applied: bool,
102}
103
104// ── public API ────────────────────────────────────────────────────────────────
105
106/// Install AgentSec by patching `~/.claude.json` and optionally
107/// `~/.claude/settings.json`.
108///
109/// When `plan.apply` is `false` (the default), no files are written; the
110/// returned [`InstallReport`] describes what *would* happen.
111pub fn install(paths: &Paths, plan: &InstallPlan) -> Result<InstallReport> {
112    let mcp_path = paths.user_home.join(".claude.json");
113    let hooks_path = paths.user_home.join(".claude/settings.json");
114
115    // ── MCP entry ──
116    let mut mcp_root = load_json_or_empty(&mcp_path)?;
117    let mcp_kind = mcp::add_mcp_entry(
118        &mut mcp_root,
119        plan.scope,
120        plan.dotenv.as_deref(),
121        plan.force,
122    );
123    let mcp_change = Some(MutationChange {
124        target: mcp_path.clone(),
125        kind: mcp_kind.clone(),
126        description: format!("MCP entry (scope={:?}): {:?}", plan.scope, mcp_kind),
127    });
128
129    // ── Hooks ──
130    let mut hooks_root = if plan.with_hooks {
131        load_json_or_empty(&hooks_path)?
132    } else {
133        Value::Null
134    };
135    let raw_hook_kinds = if plan.with_hooks {
136        hooks::add_hooks(&mut hooks_root, plan.force)
137    } else {
138        vec![]
139    };
140    let hook_names = ["user-prompt-submit", "session-start"];
141    let hooks_changes: Vec<MutationChange> = raw_hook_kinds
142        .into_iter()
143        .enumerate()
144        .map(|(i, kind)| {
145            let name = hook_names.get(i).copied().unwrap_or("unknown");
146            MutationChange {
147                target: hooks_path.clone(),
148                kind: kind.clone(),
149                description: format!("hook `agentsec hook {name}`: {kind:?}"),
150            }
151        })
152        .collect();
153
154    // ── Apply ──
155    //
156    // Per-target NoOp skip: if the only change for a file is `NoOp`,
157    // skip both the backup and the write — there is literally nothing
158    // to persist, and creating an `.bak.<epoch>` of an unchanged file
159    // just litters the home dir on every dry-run-becomes-real run.
160    let mcp_dirty = mcp_kind != ChangeKind::NoOp;
161    let hooks_dirty = hooks_changes.iter().any(|c| c.kind != ChangeKind::NoOp);
162
163    let mut backups = Vec::new();
164    if plan.apply {
165        // MCP
166        if mcp_dirty {
167            let bak = backup::backup(&mcp_path)?;
168            if !bak.as_os_str().is_empty() {
169                backups.push(bak);
170            }
171            write_json(&mcp_path, &mcp_root)?;
172        }
173
174        // Hooks
175        if plan.with_hooks && hooks_dirty {
176            // Ensure parent dir exists.
177            if let Some(parent) = hooks_path.parent() {
178                std::fs::create_dir_all(parent)?;
179            }
180            let bak = backup::backup(&hooks_path)?;
181            if !bak.as_os_str().is_empty() {
182                backups.push(bak);
183            }
184            write_json(&hooks_path, &hooks_root)?;
185        }
186    }
187
188    Ok(InstallReport {
189        mcp_change,
190        hooks_changes,
191        backups,
192        applied: plan.apply,
193    })
194}
195
196/// Uninstall AgentSec by removing entries from `~/.claude.json` and
197/// `~/.claude/settings.json`.
198///
199/// When `plan.apply` is `false`, no files are written.
200pub fn uninstall(paths: &Paths, plan: &UninstallPlan) -> Result<InstallReport> {
201    let mcp_path = paths.user_home.join(".claude.json");
202    let hooks_path = paths.user_home.join(".claude/settings.json");
203
204    // ── MCP entry ──
205    let mcp_change = if plan.keep_mcp {
206        None
207    } else {
208        let mut mcp_root = load_json_or_empty(&mcp_path)?;
209        let kind = mcp::remove_mcp_entry(&mut mcp_root, plan.scope);
210        Some((
211            mcp_root,
212            MutationChange {
213                target: mcp_path.clone(),
214                kind: kind.clone(),
215                description: format!("MCP entry (scope={:?}): {:?}", plan.scope, kind),
216            },
217        ))
218    };
219
220    // ── Hooks ──
221    let hooks_result = if plan.keep_hooks {
222        None
223    } else {
224        let mut hooks_root = load_json_or_empty(&hooks_path)?;
225        let raw_kinds = hooks::remove_hooks(&mut hooks_root);
226        Some((hooks_root, raw_kinds))
227    };
228
229    let hook_names = ["user-prompt-submit", "session-start"];
230    let hooks_changes: Vec<MutationChange> = hooks_result
231        .as_ref()
232        .map(|(_, kinds)| {
233            kinds
234                .iter()
235                .enumerate()
236                .map(|(i, kind)| {
237                    let name = hook_names.get(i).copied().unwrap_or("unknown");
238                    MutationChange {
239                        target: hooks_path.clone(),
240                        kind: kind.clone(),
241                        description: format!("hook `agentsec hook {name}`: {kind:?}"),
242                    }
243                })
244                .collect()
245        })
246        .unwrap_or_default();
247
248    let mcp_change_report = mcp_change.as_ref().map(|(_, c)| c.clone());
249
250    // ── Apply ──
251    //
252    // Per-target NoOp skip (parallel to `install`): when the only
253    // change for a target is `NoOp`, skip both backup and write —
254    // otherwise re-running `uninstall --apply` on an already-clean
255    // host produces a `.bak.<epoch>` of an unchanged file every time.
256    let mcp_dirty = mcp_change
257        .as_ref()
258        .is_some_and(|(_, c)| c.kind != ChangeKind::NoOp);
259    let hooks_dirty = hooks_changes.iter().any(|c| c.kind != ChangeKind::NoOp);
260
261    let mut backups = Vec::new();
262    if plan.apply {
263        if mcp_dirty && let Some((mcp_root, _)) = &mcp_change {
264            let bak = backup::backup(&mcp_path)?;
265            if !bak.as_os_str().is_empty() {
266                backups.push(bak);
267            }
268            write_json(&mcp_path, mcp_root)?;
269        }
270        if hooks_dirty && let Some((hooks_root, _)) = &hooks_result {
271            let bak = backup::backup(&hooks_path)?;
272            if !bak.as_os_str().is_empty() {
273                backups.push(bak);
274            }
275            write_json(&hooks_path, hooks_root)?;
276        }
277    }
278
279    Ok(InstallReport {
280        mcp_change: mcp_change_report,
281        hooks_changes,
282        backups,
283        applied: plan.apply,
284    })
285}
286
287// ── private helpers ───────────────────────────────────────────────────────────
288
289fn load_json_or_empty(path: &std::path::Path) -> Result<Value> {
290    if !path.exists() {
291        return Ok(Value::Object(serde_json::Map::new()));
292    }
293    let body = std::fs::read_to_string(path)?;
294    if body.trim().is_empty() {
295        return Ok(Value::Object(serde_json::Map::new()));
296    }
297    serde_json::from_str(&body)
298        .map_err(|e| Error::Installer(format!("JSON parse error in {}: {e}", path.display())))
299}
300
301fn write_json(path: &std::path::Path, value: &Value) -> Result<()> {
302    if let Some(parent) = path.parent() {
303        std::fs::create_dir_all(parent)?;
304    }
305    let content = serde_json::to_string_pretty(value)?;
306    std::fs::write(path, content)?;
307    Ok(())
308}
309
310// ── unit tests ────────────────────────────────────────────────────────────────
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::LlmConfig;
316    use crate::{Config, Paths};
317    use tempfile::TempDir;
318
319    fn test_paths(tmp: &TempDir) -> Paths {
320        Paths {
321            home: tmp.path().to_path_buf(),
322            user_home: tmp.path().to_path_buf(),
323        }
324    }
325
326    fn test_cfg(tmp: &TempDir) -> Config {
327        Config {
328            paths: test_paths(tmp),
329            llm: LlmConfig {
330                api_key: None,
331                model: "claude-sonnet-4-5".to_string(),
332            },
333            paste: crate::config::PasteConfig::default(),
334            web: crate::config::WebConfig::default(),
335            dotenv_path: None,
336        }
337    }
338
339    #[test]
340    fn install_dry_run_no_file_mutation() {
341        let tmp = TempDir::new().unwrap();
342        let paths = test_paths(&tmp);
343        let plan = InstallPlan {
344            scope: InstallScope::User,
345            dotenv: None,
346            with_hooks: true,
347            force: false,
348            apply: false,
349        };
350        let report = install(&paths, &plan).unwrap();
351        assert!(!report.applied);
352        assert!(report.backups.is_empty());
353        // No files written
354        assert!(!tmp.path().join(".claude.json").exists());
355        assert!(!tmp.path().join(".claude/settings.json").exists());
356    }
357
358    #[test]
359    fn install_apply_creates_files() {
360        let tmp = TempDir::new().unwrap();
361        let paths = test_paths(&tmp);
362        let plan = InstallPlan {
363            scope: InstallScope::User,
364            dotenv: None,
365            with_hooks: true,
366            force: false,
367            apply: true,
368        };
369        let report = install(&paths, &plan).unwrap();
370        assert!(report.applied);
371        assert!(tmp.path().join(".claude.json").exists());
372        assert!(tmp.path().join(".claude/settings.json").exists());
373        // Verify MCP entry
374        let mcp_content = std::fs::read_to_string(tmp.path().join(".claude.json")).unwrap();
375        let mcp_json: Value = serde_json::from_str(&mcp_content).unwrap();
376        assert!(mcp_json["mcpServers"]["agentsec"].is_object());
377    }
378
379    #[test]
380    fn install_twice_is_idempotent() {
381        let tmp = TempDir::new().unwrap();
382        let paths = test_paths(&tmp);
383        let plan = InstallPlan {
384            scope: InstallScope::User,
385            dotenv: None,
386            with_hooks: true,
387            force: false,
388            apply: true,
389        };
390        install(&paths, &plan).unwrap();
391        install(&paths, &plan).unwrap();
392
393        let hooks_content =
394            std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
395        let hooks_json: Value = serde_json::from_str(&hooks_content).unwrap();
396        // Should still have exactly 1 entry per event.
397        let ups_len = hooks_json["hooks"]["UserPromptSubmit"]
398            .as_array()
399            .map_or(0, Vec::len);
400        assert_eq!(ups_len, 1, "should not duplicate hook entries");
401    }
402
403    #[test]
404    fn install_apply_creates_backup_when_file_existed() {
405        let tmp = TempDir::new().unwrap();
406        let paths = test_paths(&tmp);
407        // Pre-create target files.
408        std::fs::write(tmp.path().join(".claude.json"), "{}").unwrap();
409        std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
410        std::fs::write(tmp.path().join(".claude/settings.json"), "{}").unwrap();
411
412        let plan = InstallPlan {
413            scope: InstallScope::User,
414            dotenv: None,
415            with_hooks: true,
416            force: false,
417            apply: true,
418        };
419        let report = install(&paths, &plan).unwrap();
420        assert_eq!(report.backups.len(), 2, "should create 2 backups");
421        for bak in &report.backups {
422            assert!(bak.exists());
423        }
424    }
425
426    #[test]
427    fn install_apply_skips_backup_when_all_changes_are_noop() {
428        // Idempotency check: after a successful first apply, a second
429        // apply against the same home dir should produce ChangeKind::NoOp
430        // for every target and therefore skip backup creation entirely.
431        // Without this skip, every re-run would litter the home with
432        // an `.bak.<epoch>` of an unchanged file.
433        let tmp = TempDir::new().unwrap();
434        let paths = test_paths(&tmp);
435        let plan = InstallPlan {
436            scope: InstallScope::User,
437            dotenv: None,
438            with_hooks: true,
439            force: false,
440            apply: true,
441        };
442        // First apply: creates the files (backup count = 0 because
443        // files didn't pre-exist; that's the existing semantics).
444        let first = install(&paths, &plan).unwrap();
445        assert!(first.applied);
446        // Second apply: all NoOp, so no backup should be produced.
447        let second = install(&paths, &plan).unwrap();
448        assert!(second.applied);
449        assert_eq!(
450            second.mcp_change.as_ref().unwrap().kind,
451            super::ChangeKind::NoOp
452        );
453        assert!(
454            second
455                .hooks_changes
456                .iter()
457                .all(|c| c.kind == super::ChangeKind::NoOp),
458            "all hooks must report NoOp on second apply"
459        );
460        assert_eq!(
461            second.backups.len(),
462            0,
463            "second apply must not create backups when nothing changes"
464        );
465    }
466
467    #[test]
468    fn uninstall_apply_skips_backup_when_nothing_to_remove() {
469        // Re-running `uninstall --apply` on an already-clean host should
470        // be a no-op: no backup, no write.
471        let tmp = TempDir::new().unwrap();
472        let paths = test_paths(&tmp);
473        // Pre-create empty target files so load_json_or_empty succeeds
474        // but contains nothing to remove.
475        std::fs::write(tmp.path().join(".claude.json"), "{}").unwrap();
476        std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
477        std::fs::write(tmp.path().join(".claude/settings.json"), "{}").unwrap();
478
479        let plan = UninstallPlan {
480            scope: InstallScope::User,
481            keep_mcp: false,
482            keep_hooks: false,
483            apply: true,
484        };
485        let report = uninstall(&paths, &plan).unwrap();
486        assert!(report.applied);
487        assert_eq!(
488            report.mcp_change.as_ref().unwrap().kind,
489            super::ChangeKind::NoOp
490        );
491        assert!(
492            report
493                .hooks_changes
494                .iter()
495                .all(|c| c.kind == super::ChangeKind::NoOp)
496        );
497        assert_eq!(
498            report.backups.len(),
499            0,
500            "uninstall on clean host must not create backups"
501        );
502    }
503
504    // Config helper (used by test_cfg above but needs to compile).
505    #[allow(dead_code)]
506    fn _use_cfg(tmp: &TempDir) {
507        let _ = test_cfg(tmp);
508    }
509}