From c7a95f84b389d64bce0cc6dbd2d893fd5a0577c3 Mon Sep 17 00:00:00 2001 From: chess Date: Sun, 16 Aug 2026 02:30:32 +0000 Subject: [PATCH] Add endpoint protection checks to `codex doctor` (#38827) ## Why Endpoint protection products can interfere with Codex, but existing doctor reports do not identify them or explain which Codex exclusions need verification. ## What changed - Detect supported endpoint protection products on macOS and Windows and add the results to the doctor environment report. - Warn when detected products have unverified Codex exclusions, with product-specific remediation for CrowdStrike Falcon, BeyondTrust Privilege Management, Microsoft Defender, SentinelOne, and Jamf Protect. - Distinguish complete, partial, and unavailable inspection results without requiring remediation when no product is detected. ## Testing - Cover empty, unavailable, partial, single-product, and multi-product inspection results. - Verify the structured JSON contract and human-readable report output. GitOrigin-RevId: 684c69684ed73235d92d398f5294cc1b274278df --- codex-rs/cli/src/doctor.rs | 2 + codex-rs/cli/src/doctor/output.rs | 14 +- codex-rs/cli/src/doctor/security.rs | 254 ++++++++++++++++++ codex-rs/cli/src/doctor/security_tests.rs | 184 +++++++++++++ ..._doctor_human_report_environment_rows.snap | 8 +- 5 files changed, 459 insertions(+), 3 deletions(-) create mode 100644 codex-rs/cli/src/doctor/security.rs create mode 100644 codex-rs/cli/src/doctor/security_tests.rs diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index aaf48fee38..65e5ed14b7 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -72,6 +72,7 @@ mod git; mod output; mod progress; mod runtime; +mod security; mod system; mod thread_inventory; mod title; @@ -343,6 +344,7 @@ async fn build_report( let progress = doctor_progress(command.json); let mut checks = Vec::new(); checks.push(run_sync_check("system", progress.clone(), system_check)); + checks.push(run_async_check("endpoint protection", progress.clone(), security::check()).await); checks.push(run_sync_check("installation", progress.clone(), || { installation_check(!command.summary) })); diff --git a/codex-rs/cli/src/doctor/output.rs b/codex-rs/cli/src/doctor/output.rs index af71870cf3..ced88b277c 100644 --- a/codex-rs/cli/src/doctor/output.rs +++ b/codex-rs/cli/src/doctor/output.rs @@ -26,8 +26,8 @@ const GROUPS: &[OutputGroup] = &[ OutputGroup { title: "Environment", keys: &[ - "system", "disk", "runtime", "install", "search", "git", "terminal", "title", "state", - "threads", + "system", "disk", "security", "runtime", "install", "search", "git", "terminal", + "title", "state", "threads", ], }, OutputGroup { @@ -1319,6 +1319,16 @@ Background Server "create a trusted Windows Dev Drive: https://learn.microsoft.com/en-us/windows/dev-drive/", ), ); + let mut security = super::super::security::endpoint_check( + super::super::security::EndpointInspection::Complete(vec!["Microsoft Defender"]), + ); + let targets = security + .details + .iter_mut() + .find(|detail| detail.starts_with("exclusion targets: ")) + .expect("endpoint security check should include exclusion targets"); + *targets = "exclusion targets: verified Codex app and required helpers".into(); + report.checks.push(security); insta::assert_snapshot!( "doctor_human_report_environment_rows", render_human_report(&report, detailed_no_color_unicode_options()) diff --git a/codex-rs/cli/src/doctor/security.rs b/codex-rs/cli/src/doctor/security.rs new file mode 100644 index 0000000000..0413f9c0a2 --- /dev/null +++ b/codex-rs/cli/src/doctor/security.rs @@ -0,0 +1,254 @@ +#[cfg(any(target_os = "macos", target_os = "windows"))] +use std::ffi::OsStr; +#[cfg(target_os = "windows")] +use std::path::PathBuf; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use std::process::Output; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use std::process::Stdio; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use std::time::Duration; + +#[cfg(any(target_os = "macos", target_os = "windows"))] +use tokio::process::Command; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use tokio::time::timeout; + +use super::CheckStatus; +use super::DoctorCheck; +use super::DoctorIssue; + +#[cfg(any(target_os = "macos", target_os = "windows"))] +const PRODUCT_QUERY_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(any(target_os = "macos", target_os = "windows"))] +const MAX_PRODUCT_OUTPUT_BYTES: usize = 64 * 1024; + +pub(super) enum EndpointInspection { + Complete(Vec<&'static str>), + #[cfg(any(target_os = "windows", test))] + Partial(Vec<&'static str>), + #[cfg(any(target_os = "macos", target_os = "windows", test))] + Unavailable, +} + +pub(super) async fn check() -> DoctorCheck { + endpoint_check(endpoint_products().await) +} + +pub(super) fn endpoint_check(inspection: EndpointInspection) -> DoctorCheck { + let (products, visibility_incomplete) = match inspection { + EndpointInspection::Complete(products) => (products, false), + #[cfg(any(target_os = "windows", test))] + EndpointInspection::Partial(products) => (products, true), + #[cfg(any(target_os = "macos", target_os = "windows", test))] + EndpointInspection::Unavailable => { + return DoctorCheck::new( + "security.endpoint", + "security", + CheckStatus::Warning, + "endpoint protection inspection unavailable", + ) + .detail("endpoint products: unavailable"); + } + }; + + if products.is_empty() { + let (summary, detail) = if cfg!(any(target_os = "macos", target_os = "windows")) { + ("no supported endpoint protection detected", "none detected") + } else { + ( + "endpoint protection is not inspected on this platform", + "not inspected on this platform", + ) + }; + return DoctorCheck::new("security.endpoint", "security", CheckStatus::Ok, summary) + .detail(format!("endpoint products: {detail}")); + } + + let targets = if cfg!(target_os = "windows") { + "signed Codex app; codex.exe; codex-windows-sandbox-setup.exe; codex-command-runner.exe; codex-code-mode-host.exe" + } else if cfg!(target_os = "macos") { + "signing team 2DC432GLL2 plus the installed Codex app identity, signed codex agent, and required helpers" + } else { + "verified Codex app, codex agent, and required helpers" + }; + let mut check = DoctorCheck::new( + "security.endpoint", + "security", + CheckStatus::Warning, + "endpoint protection detected; Codex exclusions are unverified", + ) + .detail(format!("endpoint products: {}", products.join(", "))) + .detail(format!("exclusion targets: {targets}")) + .detail("Codex exclusions: not verified") + .remediation( + "ask your security administrator to verify Codex exclusions and required helper allowances", + ); + if visibility_incomplete { + check = check.detail("additional endpoint products: unavailable"); + } + + for product in products { + let remedy = match product { + "CrowdStrike Falcon" => { + "CrowdStrike Falcon: Add a certificate or IOA exclusion for Codex. If sensor overhead continues, exclude the Codex agent from sensor visibility. Keep monitoring descendant processes." + } + "BeyondTrust Privilege Management" => { + "BeyondTrust: Remove Codex from application blocking rules. Add allow rules for Codex helper executables. Do not grant administrator privileges." + } + "Microsoft Defender" => { + "Microsoft Defender: Add a certificate or executable-path exclusion for Codex and its helpers. If Attack Surface Reduction blocks Codex, add a rule exclusion. If Controlled Folder Access blocks Codex, allow the app." + } + "SentinelOne" => { + "SentinelOne: Add a signer, file-hash, or executable-path exclusion for Codex and its helpers." + } + "Jamf Protect" => { + "Jamf Protect: Add an Override Threat Prevention exception for Codex app and helper signing identities. If analytics cause delays, add an Ignore System Events for Analytics exception." + } + _ => { + "Add an exclusion for Codex and its helpers. Use the endpoint product instructions." + } + }; + check = check.issue( + DoctorIssue::new( + CheckStatus::Warning, + format!("{product} can interfere with Codex. Verify Codex exclusions."), + ) + .measured("not verified") + .expected("Codex application and helper exclusions") + .remedy(remedy) + .field("Codex exclusions"), + ); + } + + check +} + +async fn endpoint_products() -> EndpointInspection { + #[cfg(target_os = "windows")] + { + let system32 = std::env::var_os("SystemRoot") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Windows")) + .join("System32"); + let service = system32.join("sc.exe"); + let (crowdstrike, beyondtrust, defender, sentinelone) = tokio::join!( + product_command(&service, &["query", "CSFalconService"]), + product_command(&service, &["query", "DefendpointService"]), + product_command(&service, &["query", "WinDefend"]), + product_command(&service, &["query", "SentinelAgent"]), + ); + + let mut products = Vec::new(); + let mut visibility_incomplete = false; + for (product, output) in [ + ("CrowdStrike Falcon", crowdstrike), + ("BeyondTrust Privilege Management", beyondtrust), + ("Microsoft Defender", defender), + ("SentinelOne", sentinelone), + ] { + match output { + Some(output) if output.status.success() => { + if product != "Microsoft Defender" + || String::from_utf8_lossy(&output.stdout).lines().any(|line| { + line.split_once(':').is_some_and(|(_, value)| { + value.split_whitespace().next() == Some("4") + }) + }) + { + products.push(product); + } + } + Some(output) if output.status.code() == Some(1060) => {} + Some(_) | None => visibility_incomplete = true, + } + } + + if visibility_incomplete { + if products.is_empty() { + EndpointInspection::Unavailable + } else { + EndpointInspection::Partial(products) + } + } else { + EndpointInspection::Complete(products) + } + } + + #[cfg(target_os = "macos")] + { + const PRODUCTS: &[(&str, &str, &str)] = &[ + ( + "CrowdStrike Falcon", + "X9E956P446", + "com.crowdstrike.falcon.Agent", + ), + ( + "BeyondTrust Privilege Management", + "2ZS8T6NYB8", + "com.beyondtrust.endpointsecurity", + ), + ( + "Microsoft Defender", + "UBF8T346G9", + "com.microsoft.wdav.epsext", + ), + ( + "SentinelOne", + "4AYE5J54KN", + "com.sentinelone.network-monitoring", + ), + ( + "Jamf Protect", + "483DWKW443", + "com.jamf.protect.security-extension", + ), + ]; + let Some(output) = product_command("/usr/bin/systemextensionsctl", &["list"]) + .await + .filter(|output| output.status.success()) + else { + return EndpointInspection::Unavailable; + }; + EndpointInspection::Complete( + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let mut columns = line.split_whitespace(); + if columns.next() != Some("*") || columns.next() != Some("*") { + return None; + } + let team = columns.next()?; + let bundle = columns.next()?; + PRODUCTS.iter().find_map(|&(name, signer, identifier)| { + (team == signer && bundle == identifier).then_some(name) + }) + }) + .collect(), + ) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + EndpointInspection::Complete(Vec::new()) + } +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +async fn product_command(program: impl AsRef, args: &[&str]) -> Option { + let mut command = Command::new(program); + command + .args(args) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + timeout(PRODUCT_QUERY_TIMEOUT, command.output()) + .await + .ok()? + .ok() + .filter(|output| output.stdout.len() <= MAX_PRODUCT_OUTPUT_BYTES) +} + +#[cfg(test)] +#[path = "security_tests.rs"] +mod tests; diff --git a/codex-rs/cli/src/doctor/security_tests.rs b/codex-rs/cli/src/doctor/security_tests.rs new file mode 100644 index 0000000000..41aec9bf26 --- /dev/null +++ b/codex-rs/cli/src/doctor/security_tests.rs @@ -0,0 +1,184 @@ +use pretty_assertions::assert_eq; + +use super::super::redacted_json_check; +use super::CheckStatus; +use super::EndpointInspection; +use super::endpoint_check; + +#[test] +fn no_endpoint_products_require_no_remediation() { + let check = endpoint_check(EndpointInspection::Complete(Vec::new())); + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!(check.remediation, None); + assert!(check.issues.is_empty()); +} + +#[test] +fn unavailable_endpoint_inspection_does_not_require_remediation() { + let check = endpoint_check(EndpointInspection::Unavailable); + + assert_eq!(check.status, CheckStatus::Warning); + assert!(check.summary.to_ascii_lowercase().contains("unavailable")); + assert_eq!(check.details, vec!["endpoint products: unavailable"]); + assert_eq!(check.remediation, None); + assert!(check.issues.is_empty()); +} + +#[test] +fn each_endpoint_product_requires_vendor_specific_app_exclusions() { + let cases: [(&str, &[&str]); 5] = [ + ("CrowdStrike Falcon", &["certificate", "ioa"]), + ("BeyondTrust Privilege Management", &["application"]), + ("Microsoft Defender", &["executable", "path", "certificate"]), + ("SentinelOne", &["signer", "hash", "path"]), + ("Jamf Protect", &["threat prevention"]), + ]; + + for (product, strategies) in cases { + let check = endpoint_check(EndpointInspection::Complete(vec![product])); + + assert_eq!(check.status, CheckStatus::Warning, "{product}"); + assert!( + check + .details + .iter() + .any(|detail| detail.starts_with("exclusion targets: ")), + "{product}: {:?}", + check.details + ); + let remediation = check + .remediation + .as_deref() + .expect("detected endpoint protection should require remediation") + .to_ascii_lowercase(); + assert_eq!( + remediation, + "ask your security administrator to verify codex exclusions and required helper allowances", + "{product}" + ); + assert!( + !serde_json::to_string(&redacted_json_check(&check)) + .expect("serialize endpoint check") + .to_ascii_lowercase() + .contains("chatgpt"), + "{product}: endpoint diagnostics should only refer to Codex" + ); + + assert_eq!(check.issues.len(), 1, "{product}"); + let issue = &check.issues[0]; + assert_eq!(issue.severity, CheckStatus::Warning, "{product}"); + assert!( + issue + .cause + .to_ascii_lowercase() + .contains(&product.to_ascii_lowercase()), + "{product}: {}", + issue.cause + ); + assert!( + issue.cause.contains("Verify Codex exclusions."), + "{product}: {}", + issue.cause + ); + + let remedy = issue + .remedy + .as_deref() + .expect("endpoint product should have vendor-specific guidance") + .to_ascii_lowercase(); + assert!( + strategies.iter().any(|strategy| remedy.contains(strategy)), + "{product}: {remedy}" + ); + assert!(!remedy.contains("administrator-approved"), "{product}"); + assert!(!remedy.contains(';'), "{product}"); + for sentence in remedy.split('.') { + assert!( + sentence.split_whitespace().count() <= 20, + "{product}: {sentence}" + ); + } + + if product == "BeyondTrust Privilege Management" { + assert!(remedy.contains("rule"), "{product}: {remedy}"); + assert!(remedy.contains("blocking"), "{product}: {remedy}"); + assert!( + remedy.contains("do not grant administrator privileges"), + "{product}: {remedy}" + ); + } + + if product == "Microsoft Defender" { + assert!( + remedy.contains("attack surface reduction"), + "{product}: {remedy}" + ); + assert!( + remedy.contains("controlled folder access"), + "{product}: {remedy}" + ); + } + + if product == "CrowdStrike Falcon" { + assert!(remedy.contains("sensor visibility"), "{product}: {remedy}"); + assert!(remedy.contains("descendant"), "{product}: {remedy}"); + } + + if product == "Jamf Protect" { + assert!(remedy.contains("exception"), "{product}: {remedy}"); + assert!(remedy.contains("signing"), "{product}: {remedy}"); + } + } +} + +#[test] +fn multiple_endpoint_products_each_get_distinct_guidance() { + let products = ["CrowdStrike Falcon", "Microsoft Defender"]; + let check = endpoint_check(EndpointInspection::Complete(products.to_vec())); + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!(check.issues.len(), products.len()); + + for product in products { + let matching_issues = check + .issues + .iter() + .filter(|issue| { + issue + .cause + .to_ascii_lowercase() + .contains(&product.to_ascii_lowercase()) + }) + .count(); + + assert_eq!(matching_issues, 1, "{product}"); + } +} + +#[test] +fn endpoint_product_guidance_preserves_structured_json_contract() { + let check = endpoint_check(EndpointInspection::Complete(vec!["Microsoft Defender"])); + let json = serde_json::to_value(redacted_json_check(&check)).expect("serialize endpoint check"); + + assert_eq!(json["id"], "security.endpoint"); + assert_eq!(json["category"], "security"); + assert_eq!(json["status"], "warning"); + assert_eq!(json["details"]["endpoint products"], "Microsoft Defender"); + assert!(json["details"]["exclusion targets"].is_string()); + assert_eq!(json["issues"].as_array().map(Vec::len), Some(1)); + assert!(json["issues"][0]["remedy"].is_string()); +} + +#[test] +fn detected_products_preserve_partial_inspection_visibility() { + let check = endpoint_check(EndpointInspection::Partial(vec!["Microsoft Defender"])); + + assert_eq!(check.status, CheckStatus::Warning); + assert!( + check + .details + .contains(&"additional endpoint products: unavailable".to_string()) + ); + assert_eq!(check.issues.len(), 1); +} diff --git a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap index 7235b51c3e..a384ccb8c0 100644 --- a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap +++ b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap @@ -8,6 +8,7 @@ Notes ⚠ terminal narrow terminal ✗ auth token expired - Run `codex login`. ⚠ git this worktree is not on a Windows Dev Drive - create a trusted Windows Dev Drive: https://learn.microsoft.com/en-us/windows/dev-drive/ + ⚠ security Microsoft Defender can interfere with Codex. Verify Codex exclusions. ───────────────────────────────────────────────────────────── Environment @@ -21,6 +22,11 @@ Environment GH_PAGER less LESS -FRX ✓ disk sufficient free disk space (42.0 GiB) + ⚠ security Microsoft Defender can interfere with Codex. Verify Codex exclusions. + endpoint products Microsoft Defender + exclusion targets verified Codex app and required helpers + ▸ Codex exclusions not verified (expected Codex application and helper exclusions) + → Microsoft Defender: Add a certificate or executable-path exclusion for Codex and its helpers. If Attack Surface Reduction blocks Codex, add a rule exclusion. If Controlled Folder Access blocks Codex, allow the app. ✓ runtime running local build on darwin-arm64 ✓ install consistent managed by npm: no · bun: no · pnpm: no · package root — @@ -53,7 +59,7 @@ Background Server ✓ app-server background server is not running ───────────────────────────────────────────────────────────── -13 ok · 3 notes · 2 warn · 1 fail failed +13 ok · 4 notes · 3 warn · 1 fail failed --summary compact output --all expand truncated lists --json redacted report