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
This commit is contained in:
chess
2026-08-16 02:30:32 +00:00
committed by copyberry
parent 49db349ffd
commit c7a95f84b3
5 changed files with 459 additions and 3 deletions

View File

@@ -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)
}));

View File

@@ -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())

View File

@@ -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<OsStr>, args: &[&str]) -> Option<Output> {
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;

View File

@@ -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);
}

View File

@@ -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