mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
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:
@@ -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)
|
||||
}));
|
||||
|
||||
@@ -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())
|
||||
|
||||
254
codex-rs/cli/src/doctor/security.rs
Normal file
254
codex-rs/cli/src/doctor/security.rs
Normal 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;
|
||||
184
codex-rs/cli/src/doctor/security_tests.rs
Normal file
184
codex-rs/cli/src/doctor/security_tests.rs
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user