agentsec-core 0.2.0

AgentSec core library — scan / web / paste logic, pure Rust
Documentation
//! Edge-resolved configuration.
//!
//! [`Config`] is the single value that carries all environment-derived
//! state through the crate. Library code **never** reads `std::env`
//! directly — the binary's `main` resolves the env once into a [`Config`]
//! and passes `&Config` down the call graph.
//!
//! ## Why this lives here
//!
//! Env reads are a side effect of the process boundary. Putting them in
//! library functions makes the library:
//!
//! - **unsafe to test in parallel** (process env is global mutable state)
//! - **dependent on global ordering** (which fn ran first wins)
//! - **untestable without `unsafe { set_var }`** in tests
//!
//! By contrast, with `Config` resolved once at the outer rim and threaded
//! through as `&Config`:
//!
//! - tests construct a `Config` with literal field values; no env writes
//! - the only place that needs env-mock testing is
//!   [`Config::from_env_lookup`], which is a pure function over an
//!   injectable lookup closure
//! - the binary's `main` is the *only* place that calls
//!   [`Config::from_env`] (= `std::env::var`)
//!
//! ## What env vars are read
//!
//! | Var                        | Field                                  | Default                                  |
//! |----------------------------|----------------------------------------|------------------------------------------|
//! | `AGENTSEC_HOME`            | [`Paths::home`]                        | `$HOME/.agentsec` ↓                      |
//! | `HOME`                     | [`Paths::user_home`] (and home fallback) | `.`                                    |
//! | `ANTHROPIC_API_KEY`        | [`LlmConfig::api_key`]                 | `None` (semantic sanitize layer no-op)   |
//! | `AGENTSEC_LLM_MODEL`       | [`LlmConfig::model`]                   | `claude-haiku-4-5-20251001`              |
//! | `AGENTSEC_PASTE_THRESHOLD` | [`PasteConfig::threshold_bytes`]       | `1024`                                   |
//! | `AGENTSEC_WEB_TIMEOUT`     | [`WebConfig::timeout_secs`]            | `10`                                     |

use std::path::PathBuf;

/// Default LLM model id used when `AGENTSEC_LLM_MODEL` is not set.
pub const DEFAULT_LLM_MODEL: &str = "claude-haiku-4-5-20251001";

/// Default paste detection threshold (bytes). Content smaller than this limit
/// is still inspected; the threshold governs log verbosity in future versions.
/// Currently kept as a configuration hook for downstream callers.
pub const DEFAULT_PASTE_THRESHOLD_BYTES: u64 = 1024;

/// Default web-fetch timeout in seconds used when `AGENTSEC_WEB_TIMEOUT` is
/// not set. Distinct from the old hard-coded `DEFAULT_TIMEOUT_SECS` constant
/// in `web/fetch.rs` (which was 15 s); the new config-driven default is 10 s.
pub const DEFAULT_WEB_TIMEOUT_SECS: u64 = 10;

/// All edge-resolved runtime configuration. Constructed once by the
/// binary's `main` and passed by reference to library functions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
    /// Filesystem paths AgentSec reads / writes.
    pub paths: Paths,
    /// LLM-backed sanitize layer configuration.
    pub llm: LlmConfig,
    /// Paste detection configuration.
    pub paste: PasteConfig,
    /// Web fetch configuration.
    pub web: WebConfig,
    /// Absolute path of the `.env` file that the binary actually loaded
    /// at startup (via `AGENTSEC_DOTENV` or `dotenvy::dotenv()`), if any.
    /// `None` ⇒ no `.env` was loaded. Used by [`crate::diagnostics`] to
    /// trace env-var provenance.
    pub dotenv_path: Option<PathBuf>,
}

/// Paste detection configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PasteConfig {
    /// Paste content size threshold in bytes. Currently used as a
    /// configuration hook; future versions may skip lighter inspection
    /// paths for very small inputs below this threshold.
    /// Default: [`DEFAULT_PASTE_THRESHOLD_BYTES`] (1024).
    pub threshold_bytes: u64,
}

impl Default for PasteConfig {
    fn default() -> Self {
        Self {
            threshold_bytes: DEFAULT_PASTE_THRESHOLD_BYTES,
        }
    }
}

/// Web fetch configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebConfig {
    /// HTTP request timeout in seconds.
    /// Default: [`DEFAULT_WEB_TIMEOUT_SECS`] (10).
    pub timeout_secs: u64,
}

impl Default for WebConfig {
    fn default() -> Self {
        Self {
            timeout_secs: DEFAULT_WEB_TIMEOUT_SECS,
        }
    }
}

/// Filesystem paths. See *crate root §Runtime data root* for the layout
/// rules and *crate root §Read-only invariants* for what may / may not
/// be written.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Paths {
    /// AgentSec runtime data root (`$AGENTSEC_HOME` or `$HOME/.agentsec`).
    pub home: PathBuf,
    /// The user's home directory (`$HOME`). Used by
    /// [`crate::scan::inventory`] to build absolute paths for the
    /// `~/.claude/*` target list. Distinct from [`Self::home`].
    pub user_home: PathBuf,
}

impl Paths {
    /// `<home>/snapshots/` — scan snapshot files.
    pub fn snapshots(&self) -> PathBuf {
        self.home.join("snapshots")
    }
    /// `<home>/scans/` — SessionStart hook summary.
    pub fn scans(&self) -> PathBuf {
        self.home.join("scans")
    }
    /// `<home>/web_log/` — one JSON row per `web::fetch_and_sanitize`.
    pub fn web_log(&self) -> PathBuf {
        self.home.join("web_log")
    }
    /// `<home>/paste_log/` — one JSON row per `paste::detect`.
    pub fn paste_log(&self) -> PathBuf {
        self.home.join("paste_log")
    }
}

/// Semantic-sanitize-layer configuration. See
/// [`crate::web::sanitize::semantic_layer`] for fail-open semantics.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LlmConfig {
    /// Anthropic API key. `None` ⇒ the semantic layer is a no-op (the
    /// regex layer alone protects).
    pub api_key: Option<String>,
    /// Anthropic model id for the semantic layer. Defaults to
    /// [`DEFAULT_LLM_MODEL`].
    pub model: String,
}

impl Config {
    /// Build a [`Config`] by reading process environment variables.
    ///
    /// **This is the only function in the crate that touches
    /// `std::env`.** Call it once at the binary's outer rim
    /// (`fn main`) and thread `&Config` through everything else.
    ///
    /// `dotenv_path` carries the absolute path of the `.env` file the
    /// binary loaded at startup (so diagnostics can trace env
    /// provenance); pass `None` if no `.env` was loaded.
    pub fn from_env(dotenv_path: Option<PathBuf>) -> Self {
        let mut cfg = Self::from_env_lookup(|k| std::env::var(k).ok());
        cfg.dotenv_path = dotenv_path;
        cfg
    }

    /// Build a [`Config`] from an arbitrary lookup closure.
    ///
    /// Pure function over the injected lookup — exposed so tests can pass
    /// a `HashMap`-backed closure and verify the parse rules without
    /// touching real process env.
    ///
    /// # Examples
    ///
    /// ```
    /// use agentsec_core::config::{Config, DEFAULT_LLM_MODEL};
    /// use std::collections::HashMap;
    ///
    /// let env: HashMap<&str, &str> = [
    ///     ("AGENTSEC_HOME", "/tmp/agentsec-test"),
    ///     ("HOME", "/home/test-user"),
    /// ]
    /// .into_iter()
    /// .collect();
    ///
    /// let cfg = Config::from_env_lookup(|k| env.get(k).map(|s| s.to_string()));
    /// assert_eq!(cfg.paths.home, std::path::PathBuf::from("/tmp/agentsec-test"));
    /// assert_eq!(cfg.paths.user_home, std::path::PathBuf::from("/home/test-user"));
    /// assert_eq!(cfg.llm.api_key, None);
    /// assert_eq!(cfg.llm.model, DEFAULT_LLM_MODEL);
    /// ```
    pub fn from_env_lookup<F>(lookup: F) -> Self
    where
        F: Fn(&str) -> Option<String>,
    {
        let user_home = lookup("HOME").unwrap_or_else(|| ".".into());
        let home = lookup("AGENTSEC_HOME").map_or_else(
            || PathBuf::from(&user_home).join(".agentsec"),
            PathBuf::from,
        );
        let user_home = PathBuf::from(user_home);

        let api_key = lookup("ANTHROPIC_API_KEY").filter(|s| !s.is_empty());
        let model = lookup("AGENTSEC_LLM_MODEL").unwrap_or_else(|| DEFAULT_LLM_MODEL.to_string());

        let paste_threshold = lookup("AGENTSEC_PASTE_THRESHOLD")
            .and_then(|s| s.parse::<u64>().ok())
            .unwrap_or(DEFAULT_PASTE_THRESHOLD_BYTES);

        let web_timeout = lookup("AGENTSEC_WEB_TIMEOUT")
            .and_then(|s| s.parse::<u64>().ok())
            .unwrap_or(DEFAULT_WEB_TIMEOUT_SECS);

        Self {
            paths: Paths { home, user_home },
            llm: LlmConfig { api_key, model },
            paste: PasteConfig {
                threshold_bytes: paste_threshold,
            },
            web: WebConfig {
                timeout_secs: web_timeout,
            },
            dotenv_path: None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;

    fn map_lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
        let map: HashMap<&str, &str> = pairs.iter().copied().collect();
        move |k| map.get(k).map(|s| (*s).to_string())
    }

    #[test]
    fn empty_env_uses_all_defaults() {
        let cfg = Config::from_env_lookup(|_| None);
        // No HOME ⇒ user_home falls back to ".".
        assert_eq!(cfg.paths.user_home, PathBuf::from("."));
        // No AGENTSEC_HOME ⇒ home falls back to ./.agentsec.
        assert_eq!(cfg.paths.home, PathBuf::from("./.agentsec"));
        assert_eq!(cfg.llm.api_key, None);
        assert_eq!(cfg.llm.model, DEFAULT_LLM_MODEL);
        assert_eq!(cfg.paste.threshold_bytes, DEFAULT_PASTE_THRESHOLD_BYTES);
        assert_eq!(cfg.web.timeout_secs, DEFAULT_WEB_TIMEOUT_SECS);
    }

    #[test]
    fn home_only_derives_agentsec_home() {
        let cfg = Config::from_env_lookup(map_lookup(&[("HOME", "/home/alice")]));
        assert_eq!(cfg.paths.user_home, PathBuf::from("/home/alice"));
        assert_eq!(cfg.paths.home, PathBuf::from("/home/alice/.agentsec"));
    }

    #[test]
    fn agentsec_home_overrides_default() {
        let cfg = Config::from_env_lookup(map_lookup(&[
            ("HOME", "/home/alice"),
            ("AGENTSEC_HOME", "/var/lib/agentsec"),
        ]));
        assert_eq!(cfg.paths.home, PathBuf::from("/var/lib/agentsec"));
        // user_home is untouched by AGENTSEC_HOME — they're distinct.
        assert_eq!(cfg.paths.user_home, PathBuf::from("/home/alice"));
    }

    #[test]
    fn paths_methods_join_under_home() {
        let cfg = Config::from_env_lookup(map_lookup(&[("AGENTSEC_HOME", "/tmp/x")]));
        assert_eq!(cfg.paths.snapshots(), PathBuf::from("/tmp/x/snapshots"));
        assert_eq!(cfg.paths.scans(), PathBuf::from("/tmp/x/scans"));
        assert_eq!(cfg.paths.web_log(), PathBuf::from("/tmp/x/web_log"));
        assert_eq!(cfg.paths.paste_log(), PathBuf::from("/tmp/x/paste_log"));
    }

    #[test]
    fn paste_threshold_override_via_env() {
        let cfg = Config::from_env_lookup(map_lookup(&[("AGENTSEC_PASTE_THRESHOLD", "4096")]));
        assert_eq!(cfg.paste.threshold_bytes, 4096);
    }

    #[test]
    fn paste_threshold_invalid_env_uses_default() {
        let cfg =
            Config::from_env_lookup(map_lookup(&[("AGENTSEC_PASTE_THRESHOLD", "not-a-number")]));
        assert_eq!(cfg.paste.threshold_bytes, DEFAULT_PASTE_THRESHOLD_BYTES);
    }

    #[test]
    fn web_timeout_override_via_env() {
        let cfg = Config::from_env_lookup(map_lookup(&[("AGENTSEC_WEB_TIMEOUT", "30")]));
        assert_eq!(cfg.web.timeout_secs, 30);
    }

    #[test]
    fn web_timeout_invalid_env_uses_default() {
        let cfg = Config::from_env_lookup(map_lookup(&[("AGENTSEC_WEB_TIMEOUT", "bad")]));
        assert_eq!(cfg.web.timeout_secs, DEFAULT_WEB_TIMEOUT_SECS);
    }

    #[test]
    fn empty_api_key_treated_as_absent() {
        // Some shells export ANTHROPIC_API_KEY= when the secret isn't
        // configured; an empty string must not flip the semantic layer on.
        let cfg = Config::from_env_lookup(map_lookup(&[("ANTHROPIC_API_KEY", "")]));
        assert_eq!(cfg.llm.api_key, None);
    }

    #[test]
    fn api_key_and_model_are_picked_up() {
        let cfg = Config::from_env_lookup(map_lookup(&[
            ("ANTHROPIC_API_KEY", "sk-test"),
            ("AGENTSEC_LLM_MODEL", "claude-sonnet-4-6"),
        ]));
        assert_eq!(cfg.llm.api_key, Some("sk-test".to_string()));
        assert_eq!(cfg.llm.model, "claude-sonnet-4-6");
    }
}