agentsec-core 0.5.0

AgentSec core library — scan / web / paste logic, pure Rust
Documentation
//! Hook entry manipulation for `~/.claude/settings.json`.
//!
//! Adds or removes the `agentsec hook <name>` entries under
//! `hooks.UserPromptSubmit` and `hooks.SessionStart`. Deduplication ensures
//! re-running install never creates duplicate entries.

use serde_json::{Value, json};

use super::ChangeKind;
use crate::diagnostics::{collect_wired_hooks, extract_hook_name, hook_key_for};

/// Hook definitions the installer manages.
const MANAGED_HOOKS: &[(&str, u32)] = &[("user-prompt-submit", 10), ("session-start", 30)];

/// Append `agentsec hook <name>` matchers to `hooks.<Event>` in `root`.
///
/// Skips hooks already present (dedup by command substring). Returns one
/// [`ChangeKind`] per hook (order: `user-prompt-submit`, `session-start`).
/// The `hooks` key is created if absent; missing event arrays are
/// initialised to `[]`.
pub fn add_hooks(root: &mut Value, force: bool) -> Vec<ChangeKind> {
    // Defensive init: ensure root is an object.
    if root.as_object().is_none() {
        *root = json!({});
    }

    // Ensure `hooks` key is an object.
    {
        let obj = root.as_object_mut().unwrap();
        match obj.get("hooks") {
            None => {
                obj.insert("hooks".to_string(), json!({}));
            }
            Some(v) if !v.is_object() => {
                obj.insert("hooks".to_string(), json!({}));
            }
            _ => {}
        }
    }

    let wired = collect_wired_hooks(root);

    let mut results = Vec::new();

    for (name, timeout) in MANAGED_HOOKS {
        let event_key = hook_key_for(name);
        let command = format!("agentsec hook {name}");

        let already_wired = wired.contains(*name);

        if already_wired && !force {
            results.push(ChangeKind::NoOp);
            continue;
        }

        // Ensure the event array exists.
        {
            let hooks_obj = root.get_mut("hooks").unwrap().as_object_mut().unwrap();
            match hooks_obj.get(event_key) {
                None => {
                    hooks_obj.insert(event_key.to_string(), json!([]));
                }
                Some(v) if !v.is_array() => {
                    hooks_obj.insert(event_key.to_string(), json!([]));
                }
                _ => {}
            }
        }

        // Append a new matcher object.
        let new_matcher = json!({
            "matcher": "",
            "hooks": [
                {
                    "type": "command",
                    "command": command,
                    "timeout": timeout
                }
            ]
        });

        root["hooks"][event_key]
            .as_array_mut()
            .unwrap()
            .push(new_matcher);

        results.push(if already_wired {
            ChangeKind::Updated
        } else {
            ChangeKind::Added
        });
    }

    results
}

/// Remove all `hooks.<Event>[].hooks[]` entries whose `command` field
/// contains the substring `"agentsec hook "`.
///
/// Matcher objects whose inner `hooks` array becomes empty are also removed.
/// Returns one [`ChangeKind`] per managed hook event (order:
/// `user-prompt-submit`, `session-start`).
pub fn remove_hooks(root: &mut Value) -> Vec<ChangeKind> {
    let mut results = Vec::new();

    for (name, _timeout) in MANAGED_HOOKS {
        let event_key = hook_key_for(name);

        let Some(matchers) = root
            .get_mut("hooks")
            .and_then(|h| h.get_mut(event_key))
            .and_then(|v| v.as_array_mut())
        else {
            results.push(ChangeKind::NoOp);
            continue;
        };

        let mut removed_any = false;

        // Remove agentsec inner hooks; drop matchers that become empty.
        matchers.retain_mut(|matcher| {
            if let Some(inner) = matcher.get_mut("hooks").and_then(|v| v.as_array_mut()) {
                let before = inner.len();
                inner.retain(|h| {
                    let is_agentsec = h
                        .get("command")
                        .and_then(|v| v.as_str())
                        .is_some_and(|cmd| extract_hook_name(cmd).is_some());
                    if is_agentsec {
                        removed_any = true;
                    }
                    !is_agentsec
                });
                let after = inner.len();
                // Keep the matcher only if it still has inner hooks.
                before == after || !inner.is_empty()
            } else {
                true
            }
        });

        results.push(if removed_any {
            ChangeKind::Removed
        } else {
            ChangeKind::NoOp
        });
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn settings_with_hooks(event: &str, command: &str) -> Value {
        json!({
            "hooks": {
                event: [
                    {
                        "matcher": "",
                        "hooks": [
                            {"type": "command", "command": command, "timeout": 10}
                        ]
                    }
                ]
            }
        })
    }

    #[test]
    fn add_hooks_adds_both_events() {
        let mut root = json!({});
        let changes = add_hooks(&mut root, false);
        assert_eq!(changes.len(), 2);
        assert_eq!(changes[0], ChangeKind::Added);
        assert_eq!(changes[1], ChangeKind::Added);
        // Verify command strings
        let ups_cmd = root["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
            .as_str()
            .unwrap();
        assert_eq!(ups_cmd, "agentsec hook user-prompt-submit");
        let ss_cmd = root["hooks"]["SessionStart"][0]["hooks"][0]["command"]
            .as_str()
            .unwrap();
        assert_eq!(ss_cmd, "agentsec hook session-start");
    }

    #[test]
    fn add_hooks_dedup_skips_existing() {
        let mut root = settings_with_hooks("UserPromptSubmit", "agentsec hook user-prompt-submit");
        // Add SessionStart manually too so both are present.
        root["hooks"]["SessionStart"] = json!([{
            "matcher": "",
            "hooks": [{"type": "command", "command": "agentsec hook session-start", "timeout": 30}]
        }]);

        let changes = add_hooks(&mut root, false);
        assert_eq!(changes[0], ChangeKind::NoOp);
        assert_eq!(changes[1], ChangeKind::NoOp);
        // Should not have duplicated entries.
        assert_eq!(
            root["hooks"]["UserPromptSubmit"].as_array().unwrap().len(),
            1
        );
    }

    #[test]
    fn add_hooks_defensive_init_no_hooks_key() {
        let mut root = json!({"someOtherKey": "value"});
        let changes = add_hooks(&mut root, false);
        assert_eq!(changes[0], ChangeKind::Added);
        assert!(root["hooks"].is_object());
    }

    #[test]
    fn remove_hooks_removes_agentsec_entries() {
        let mut root = json!({
            "hooks": {
                "UserPromptSubmit": [
                    {
                        "matcher": "",
                        "hooks": [
                            {"type": "command", "command": "agentsec hook user-prompt-submit", "timeout": 10},
                            {"type": "command", "command": "other-tool hook something", "timeout": 5}
                        ]
                    }
                ],
                "SessionStart": [
                    {
                        "matcher": "",
                        "hooks": [
                            {"type": "command", "command": "agentsec hook session-start", "timeout": 30}
                        ]
                    }
                ]
            }
        });
        let changes = remove_hooks(&mut root);
        assert_eq!(changes[0], ChangeKind::Removed);
        assert_eq!(changes[1], ChangeKind::Removed);
        // agentsec entry removed, other-tool preserved
        let ups_hooks = &root["hooks"]["UserPromptSubmit"][0]["hooks"];
        assert_eq!(ups_hooks.as_array().unwrap().len(), 1);
        assert_eq!(ups_hooks[0]["command"], "other-tool hook something");
        // SessionStart matcher was fully removed (inner array empty)
        assert_eq!(root["hooks"]["SessionStart"].as_array().unwrap().len(), 0);
    }

    #[test]
    fn remove_hooks_noop_when_absent() {
        let mut root = json!({"hooks": {"UserPromptSubmit": [], "SessionStart": []}});
        let changes = remove_hooks(&mut root);
        assert_eq!(changes[0], ChangeKind::NoOp);
        assert_eq!(changes[1], ChangeKind::NoOp);
    }

    #[test]
    fn remove_hooks_preserves_non_agentsec_entries() {
        let mut root = json!({
            "hooks": {
                "UserPromptSubmit": [
                    {
                        "matcher": "",
                        "hooks": [
                            {"type": "command", "command": "some-other-tool run", "timeout": 5}
                        ]
                    }
                ],
                "SessionStart": []
            }
        });
        let changes = remove_hooks(&mut root);
        assert_eq!(changes[0], ChangeKind::NoOp);
        // The other-tool entry must survive.
        assert_eq!(
            root["hooks"]["UserPromptSubmit"][0]["hooks"]
                .as_array()
                .unwrap()
                .len(),
            1
        );
    }
}