diff --git a/Cargo.lock b/Cargo.lock index 5e86dd0..2207c91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,6 +16,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "toml", "walkdir", ] @@ -219,6 +220,12 @@ dependencies = [ "syn", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -300,6 +307,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heck" version = "0.5.0" @@ -532,6 +545,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -974,6 +997,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1144,6 +1176,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -1596,6 +1669,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 4fa657d..5aebcb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-feature tokio = { version = "1", features = ["rt-multi-thread", "macros"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } semver = "1" +toml = "0.8" [profile.release] opt-level = 3 diff --git a/README.md b/README.md index e480f8c..c4147a6 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,9 @@ agentwise scan . # Scan a specific config file agentwise scan ~/.mcp.json +# Scan a Codex MCP config +agentwise scan ~/.codex/config.toml + # Live mode: query OSV + EPSS for real-time CVE data agentwise scan . --live @@ -177,8 +180,10 @@ agentwise auto-detects and scans: - `.mcp.json` — Claude Code project-level configs - `claude_desktop_config.json` — Claude Desktop - `.cursor/mcp.json` — Cursor editor +- `~/.codex/config.toml` and `.codex/config.toml` — Codex CLI + IDE - `mcp.json` — Generic MCP configs - Any JSON file with `mcpServers` or `context_servers` passed as argument +- Any Codex `config.toml` with `[mcp_servers.]` tables passed as argument ## Threat coverage diff --git a/src/config.rs b/src/config.rs index b930b73..8d18b1b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::path::Path; +use toml::Value as TomlValue; /// Represents a parsed MCP configuration file. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -19,7 +20,7 @@ pub struct McpServer { pub args: Option>, #[serde(default)] pub env: Option>, - #[serde(default)] + #[serde(alias = "http_headers", default)] pub headers: Option>, #[serde(default)] pub url: Option, @@ -32,11 +33,20 @@ pub struct McpServer { alias = "allowed_tools", alias = "toolAllowlist", alias = "tool_allowlist", + alias = "enabled_tools", default )] pub allowed_tools: Option>, #[serde(default)] pub disabled: Option, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub bearer_token_env_var: Option, + #[serde(default)] + pub env_vars: Option>, + #[serde(default)] + pub env_http_headers: Option>, } /// Returns true when `allowedTools` is present and provides meaningful restriction. @@ -98,7 +108,7 @@ pub struct ParsedConfig { pub config: McpConfig, } -/// Parse an MCP config from a JSON string. +/// Parse an MCP config from a JSON or TOML string. /// /// Supports multiple common schema variants: /// - { "mcpServers": { ... } } @@ -109,7 +119,21 @@ pub struct ParsedConfig { /// - { "lsp": { "mcp_servers": { ... } } } /// - { "lsp": { "context_servers": { ... } } } /// - { "lsp": { "contextServers": { ... } } } -pub fn parse_config(json: &str) -> Result { +/// - [mcp_servers.] tables (Codex `config.toml`) +pub fn parse_config(content: &str) -> Result { + match parse_json_config(content) { + Ok(config) => Ok(config), + Err(json_err) => match parse_toml_config(content) { + Ok(config) => Ok(config), + Err(toml_err) => Err(format!( + "not valid JSON ({}) or TOML ({})", + json_err, toml_err + )), + }, + } +} + +fn parse_json_config(json: &str) -> Result { let root: Value = serde_json::from_str(json)?; let mut servers: HashMap = HashMap::new(); @@ -130,6 +154,63 @@ pub fn parse_config(json: &str) -> Result { }) } +fn parse_toml_config(content: &str) -> Result { + let root: TomlValue = toml::from_str(content).map_err(|e| e.to_string())?; + let Some(server_table) = root.get("mcp_servers").and_then(TomlValue::as_table) else { + return Ok(McpConfig { + mcp_servers: HashMap::new(), + }); + }; + + let mut servers = HashMap::new(); + + for (name, value) in server_table { + let Some(table) = value.as_table() else { + continue; + }; + + let mut headers = string_map(table.get("headers")); + headers.extend(string_map(table.get("http_headers"))); + + let env_http_headers = string_map(table.get("env_http_headers")); + let env_http_headers = (!env_http_headers.is_empty()).then_some(env_http_headers); + + let server = McpServer { + command: table.get("command").and_then(as_string), + args: string_array(table.get("args")), + env: { + let env = string_map(table.get("env")); + (!env.is_empty()).then_some(env) + }, + headers: (!headers.is_empty()).then_some(headers), + url: table.get("url").and_then(as_string), + transport: table.get("transport").and_then(as_string), + allowed_directories: string_array( + table + .get("allowedDirectories") + .or_else(|| table.get("allowed_directories")), + ), + allowed_tools: string_array( + table + .get("allowedTools") + .or_else(|| table.get("allowed_tools")) + .or_else(|| table.get("enabled_tools")), + ), + disabled: table.get("disabled").and_then(TomlValue::as_bool), + enabled: table.get("enabled").and_then(TomlValue::as_bool), + bearer_token_env_var: table.get("bearer_token_env_var").and_then(as_string), + env_vars: env_var_names(table.get("env_vars")), + env_http_headers, + }; + + servers.insert(name.clone(), server); + } + + Ok(McpConfig { + mcp_servers: servers, + }) +} + /// Load and parse an MCP config from a file path. pub fn load_config(path: &Path) -> Result { let content = std::fs::read_to_string(path) @@ -137,7 +218,7 @@ pub fn load_config(path: &Path) -> Result { if content.trim().is_empty() { return Err(format!( - "Failed to parse {}: file is empty. Expected JSON like {{\"mcpServers\": {{...}}}}", + "Failed to parse {}: file is empty. Expected JSON like {{\"mcpServers\": {{...}}}} or TOML like [mcp_servers.example]", path.display() )); } @@ -146,7 +227,7 @@ pub fn load_config(path: &Path) -> Result { let msg = e.to_string(); if msg.contains("EOF while parsing") || msg.contains("expected value") { format!( - "Failed to parse {}: {}. Hint: file appears empty or truncated; use valid JSON or {{}}", + "Failed to parse {}: {}. Hint: file appears empty or truncated; use valid JSON/TOML with MCP server entries", path.display(), msg ) @@ -161,6 +242,52 @@ pub fn load_config(path: &Path) -> Result { }) } +fn as_string(value: &TomlValue) -> Option { + value.as_str().map(ToOwned::to_owned) +} + +fn string_array(value: Option<&TomlValue>) -> Option> { + let values = value + .and_then(TomlValue::as_array) + .map(|arr| arr.iter().filter_map(as_string).collect::>()) + .unwrap_or_default(); + + (!values.is_empty()).then_some(values) +} + +fn string_map(value: Option<&TomlValue>) -> HashMap { + value + .and_then(TomlValue::as_table) + .map(|table| { + table + .iter() + .filter_map(|(key, value)| as_string(value).map(|v| (key.clone(), v))) + .collect() + }) + .unwrap_or_default() +} + +fn env_var_names(value: Option<&TomlValue>) -> Option> { + let values = value + .and_then(TomlValue::as_array) + .map(|arr| { + arr.iter() + .filter_map(|item| { + if let Some(name) = item.as_str() { + return Some(name.to_string()); + } + + item.as_table() + .and_then(|table| table.get("name")) + .and_then(as_string) + }) + .collect::>() + }) + .unwrap_or_default(); + + (!values.is_empty()).then_some(values) +} + fn merge_server_map( value: Option<&Value>, target: &mut HashMap, @@ -460,6 +587,44 @@ mod tests { assert!(server.headers.is_some()); } + #[test] + fn test_parse_codex_toml_config() { + let toml = r#" +[mcp_servers.github] +url = "http://api.example.com/mcp" +bearer_token_env_var = "GITHUB_TOKEN" +enabled_tools = ["search_repositories"] + +[mcp_servers.github.env_http_headers] +Authorization = "GITHUB_TOKEN" + +[mcp_servers.filesystem] +command = "npx" +args = ["-y", "@modelcontextprotocol/server-filesystem@0.5.0", "/"] +enabled = false +env_vars = ["LOCAL_TOKEN", { name = "REMOTE_TOKEN", source = "remote" }] +"#; + + let config = parse_config(toml).unwrap(); + assert_eq!(config.mcp_servers.len(), 2); + + let github = &config.mcp_servers["github"]; + assert_eq!(github.url.as_deref(), Some("http://api.example.com/mcp")); + assert_eq!(github.bearer_token_env_var.as_deref(), Some("GITHUB_TOKEN")); + assert_eq!( + github.allowed_tools, + Some(vec!["search_repositories".to_string()]) + ); + assert!(github.env_http_headers.is_some()); + + let filesystem = &config.mcp_servers["filesystem"]; + assert_eq!(filesystem.enabled, Some(false)); + assert_eq!( + filesystem.env_vars, + Some(vec!["LOCAL_TOKEN".to_string(), "REMOTE_TOKEN".to_string()]) + ); + } + #[test] fn test_extract_scoped_package() { let server = McpServer { diff --git a/src/discover.rs b/src/discover.rs index 7178440..5406cea 100644 --- a/src/discover.rs +++ b/src/discover.rs @@ -68,11 +68,28 @@ pub fn discover_configs() -> Vec { add_candidate(&mut results, &mut seen, path, "VS Code Continue (global)"); } + // Codex global + for path in codex_global_paths(&home) { + add_candidate(&mut results, &mut seen, path, "Codex (global)"); + } + // Windsurf global for path in windsurf_paths(&home) { add_candidate(&mut results, &mut seen, path, "Windsurf (global)"); } + // Codex project-level: walk up from cwd + if let Some(ref cwd) = cwd { + for dir in walk_up(cwd) { + add_candidate( + &mut results, + &mut seen, + dir.join(".codex").join("config.toml"), + "Codex (project)", + ); + } + } + // Zed settings.json for path in zed_paths(&home) { add_candidate(&mut results, &mut seen, path, "Zed"); @@ -414,6 +431,16 @@ fn windsurf_paths(home: &Option) -> Vec { paths } +fn codex_global_paths(home: &Option) -> Vec { + let mut paths = Vec::new(); + + if let Some(ref h) = home { + paths.push(h.join(".codex").join("config.toml")); + } + + paths +} + fn zed_paths(home: &Option) -> Vec { let mut paths = Vec::new(); @@ -550,6 +577,29 @@ mod tests { fs::remove_file(&tmp).ok(); } + #[test] + fn test_probe_config_codex_toml() { + let tmp = std::env::temp_dir().join("agentwise_probe_codex.toml"); + fs::write( + &tmp, + r#" +[mcp_servers.github] +url = "https://api.example.com/mcp" +enabled_tools = ["search_repositories"] + +[mcp_servers.context7] +command = "npx" +args = ["-y", "@upstash/context7-mcp"] +"#, + ) + .unwrap(); + let (exists, count, servers) = probe_config(&tmp, "Codex (project)"); + assert!(exists); + assert_eq!(count, 2); + assert_eq!(servers, vec!["context7", "github"]); + fs::remove_file(&tmp).ok(); + } + #[test] fn test_probe_zed_config_with_mcp_servers() { let content = r#"{"mcpServers": {"zed-server": {"command": "test"}}}"#; @@ -700,6 +750,21 @@ mod tests { } } + #[test] + fn test_codex_global_paths_contain_expected_segments() { + let home = Some(PathBuf::from("/home/testuser")); + let paths = codex_global_paths(&home); + for p in &paths { + let s = p.display().to_string(); + assert!(s.contains(".codex"), "path should contain '.codex': {}", s); + assert!( + s.ends_with("config.toml"), + "path should end with config.toml: {}", + s + ); + } + } + #[test] fn test_zed_paths_contain_expected_segments() { let home = Some(PathBuf::from("/home/testuser")); diff --git a/src/rules/auth.rs b/src/rules/auth.rs index 8d77a6c..63d4dc8 100644 --- a/src/rules/auth.rs +++ b/src/rules/auth.rs @@ -41,6 +41,15 @@ impl AuthRule { || key.contains("bearer") || key.contains("cookie") }) + }) || server.env_http_headers.as_ref().is_some_and(|headers| { + headers.keys().any(|k| { + let key = k.to_lowercase(); + key.contains("authorization") + || key == "x-api-key" + || key == "api-key" + || key.contains("bearer") + || key.contains("cookie") + }) }) } @@ -54,6 +63,26 @@ impl AuthRule { || joined.contains("x-api-key:") }) } + + fn has_auth_references(server: &McpServer) -> bool { + server + .bearer_token_env_var + .as_ref() + .is_some_and(|value| !value.trim().is_empty()) + || server.env_vars.as_ref().is_some_and(|vars| { + vars.iter().any(|value| { + let lowered = value.to_lowercase(); + lowered.contains("authorization") + || lowered.contains("auth") + || lowered.contains("token") + || lowered.contains("api_key") + || lowered.contains("apikey") + || lowered.contains("secret") + || lowered.contains("bearer") + || lowered.contains("password") + }) + }) + } } impl Rule for AuthRule { @@ -70,7 +99,8 @@ impl Rule for AuthRule { let has_auth = Self::has_auth_env(server) || Self::has_auth_headers(server) - || Self::has_auth_args(server); + || Self::has_auth_args(server) + || Self::has_auth_references(server); if !has_auth { findings.push(Finding { @@ -196,4 +226,31 @@ mod tests { let findings = rule.check("test", &server, "test.json"); assert_eq!(findings.len(), 1); } + + #[test] + fn test_remote_server_with_bearer_token_env_var_is_ok() { + let rule = AuthRule; + let server = McpServer { + url: Some("https://example.com/mcp".to_string()), + bearer_token_env_var: Some("FIGMA_OAUTH_TOKEN".to_string()), + ..Default::default() + }; + let findings = rule.check("figma", &server, "config.toml"); + assert!(findings.is_empty()); + } + + #[test] + fn test_remote_server_with_env_http_headers_is_ok() { + let rule = AuthRule; + let server = McpServer { + url: Some("https://example.com/mcp".to_string()), + env_http_headers: Some(HashMap::from([( + "Authorization".to_string(), + "FIGMA_OAUTH_TOKEN".to_string(), + )])), + ..Default::default() + }; + let findings = rule.check("figma", &server, "config.toml"); + assert!(findings.is_empty()); + } } diff --git a/src/scanner.rs b/src/scanner.rs index ac10bbf..b6da59b 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -11,6 +11,10 @@ use walkdir::WalkDir; /// Known MCP config file names to search for. const CONFIG_FILE_NAMES: &[&str] = &[".mcp.json", "mcp.json", "claude_desktop_config.json"]; +fn server_is_disabled(server: &crate::config::McpServer) -> bool { + server.disabled == Some(true) || server.enabled == Some(false) +} + /// Statistics from a live OSV query. #[derive(Debug, Clone)] pub struct OsvStats { @@ -60,7 +64,7 @@ pub fn scan(path: &str) -> ScanResult { for parsed_config in &configs { for (server_name, server) in &parsed_config.config.mcp_servers { // Skip disabled servers - if server.disabled == Some(true) { + if server_is_disabled(server) { continue; } servers_scanned += 1; @@ -100,7 +104,7 @@ pub async fn scan_with_live(path: &str) -> ScanResult { for parsed_config in &configs { for (server_name, server) in &parsed_config.config.mcp_servers { - if server.disabled == Some(true) { + if server_is_disabled(server) { continue; } for (package, version) in extract_package_info(server) { @@ -258,7 +262,7 @@ pub async fn scan_with_supply_chain(path: &str, live: bool) -> ScanResult { for parsed_config in &configs { for (server_name, server) in &parsed_config.config.mcp_servers { - if server.disabled == Some(true) { + if server_is_disabled(server) { continue; } @@ -386,7 +390,7 @@ pub fn scan_paths(paths: &[String]) -> ScanResult { for parsed_config in &configs { for (server_name, server) in &parsed_config.config.mcp_servers { - if server.disabled == Some(true) { + if server_is_disabled(server) { continue; } servers_scanned += 1; @@ -665,7 +669,13 @@ fn discover_configs(dir: &Path) -> Vec { } let is_config = CONFIG_FILE_NAMES.iter().any(|name| file_name == *name) - || file_name.ends_with(".mcp.json"); + || file_name.ends_with(".mcp.json") + || (file_name == "config.toml" + && path + .parent() + .and_then(|parent| parent.file_name()) + .and_then(|name| name.to_str()) + == Some(".codex")); if is_config { match load_config(path) { @@ -720,6 +730,15 @@ mod tests { assert!(result.servers_scanned > 0); } + #[test] + fn test_scan_codex_toml_config() { + let result = scan("testdata/codex-config.toml"); + assert_eq!(result.configs_scanned, 1); + assert_eq!(result.servers_scanned, 1); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].rule_id, "AW-005"); + } + #[test] fn test_scan_nonexistent() { let result = scan("nonexistent/path"); diff --git a/testdata/codex-config.toml b/testdata/codex-config.toml new file mode 100644 index 0000000..be9c597 --- /dev/null +++ b/testdata/codex-config.toml @@ -0,0 +1,12 @@ +[mcp_servers.github] +url = "http://api.example.com/mcp" +bearer_token_env_var = "GITHUB_TOKEN" +enabled_tools = ["search_repositories"] + +[mcp_servers.github.env_http_headers] +Authorization = "GITHUB_TOKEN" + +[mcp_servers.local_fs] +command = "npx" +args = ["-y", "@modelcontextprotocol/server-filesystem@0.5.0", "/"] +enabled = false diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index bd637e6..b3f8857 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -158,6 +158,23 @@ fn test_scan_nested_context_servers() { assert_eq!(parsed["servers_scanned"], 1); } +#[test] +fn test_scan_codex_toml_file() { + let output = agentwise() + .args(["scan", "testdata/codex-config.toml", "--format", "json"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let findings = parsed["findings"].as_array().unwrap(); + assert_eq!(parsed["configs_scanned"], 1); + assert_eq!(parsed["servers_scanned"], 1); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0]["rule_id"], "AW-005"); +} + #[test] fn test_json_output() { let output = agentwise() @@ -510,6 +527,37 @@ fn test_scan_auto_runs_discovery() { } } +#[test] +fn test_scan_auto_discovers_codex_config() { + let home = temp_path("codex-home", "dir"); + let codex_dir = home.join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write( + codex_dir.join("config.toml"), + r#" +[mcp_servers.github] +url = "http://api.example.com/mcp" +bearer_token_env_var = "GITHUB_TOKEN" +enabled_tools = ["search_repositories"] +"#, + ) + .unwrap(); + + let output = agentwise() + .env("HOME", &home) + .args(["scan", "--auto", "--format", "json"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!(parsed["configs_scanned"].as_u64().unwrap() >= 1); + assert!(parsed["servers_scanned"].as_u64().unwrap() >= 1); + + fs::remove_dir_all(&home).ok(); +} + // ── Nonexistent paths ─────────────────────────────────────── #[test]