Skip to content

Commit c4383e3

Browse files
Improve Codex plugin hook trust doctor
1 parent 604115c commit c4383e3

2 files changed

Lines changed: 163 additions & 138 deletions

File tree

src/agents/codex.rs

Lines changed: 156 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ use crate::errors::{Result, TraceDecayError};
2020

2121
use super::{
2222
load_json_file, load_json_file_strict, load_toml_file, safe_write_json_file,
23-
safe_write_text_file, tool_names, write_toml_file, AgentIntegration, DoctorCounters,
24-
HealthcheckContext, InstallContext, InstallScope, UpdatePluginOutcome,
23+
safe_write_text_file, write_toml_file, AgentIntegration, DoctorCounters, HealthcheckContext,
24+
InstallContext, InstallScope, UpdatePluginOutcome,
2525
};
2626

2727
/// `OpenAI` Codex CLI agent.
@@ -125,10 +125,10 @@ impl AgentIntegration for CodexIntegration {
125125
return Ok(UpdatePluginOutcome::Refreshed(refreshed));
126126
}
127127

128-
let target = if codex_plugin_manifest_path(&ctx.home).exists() {
128+
let target = if codex_plugin_manifest_path(&ctx.home).exists()
129+
|| codex_legacy_config_has_tracedecay(&ctx.home)
130+
{
129131
Some(plugin_dir.clone())
130-
} else if Self::has_legacy_config_install(&ctx.home) {
131-
return Ok(UpdatePluginOutcome::ConfigOnly);
132132
} else {
133133
None
134134
};
@@ -191,23 +191,20 @@ impl AgentIntegration for CodexIntegration {
191191

192192
fn healthcheck(&self, dc: &mut DoctorCounters, ctx: &HealthcheckContext) {
193193
eprintln!("\n\x1b[1mCodex CLI integration\x1b[0m");
194-
let local_codex_dir = ctx.project_path.join(".codex");
195194
let local_plugin_dir = codex_repo_plugin_install_dir(&ctx.project_path);
196195
if local_plugin_dir.join(".codex-plugin/plugin.json").exists() {
197-
doctor_check_plugin_dir(dc, &local_plugin_dir);
196+
doctor_check_plugin_dir(
197+
dc,
198+
&local_plugin_dir,
199+
Some(&ctx.home.join(".codex/config.toml")),
200+
);
198201
doctor_check_marketplace_entry(
199202
dc,
200203
&codex_repo_marketplace_path(&ctx.project_path),
201204
"repo marketplace",
202205
"./plugins/tracedecay",
203206
"tracedecay install --local --agent codex",
204207
);
205-
} else if local_codex_dir.join("config.toml").exists()
206-
|| local_codex_dir.join("hooks.json").exists()
207-
{
208-
doctor_check_config(dc, &local_codex_dir.join("config.toml"));
209-
doctor_check_prompt_file(dc, &ctx.project_path.join("AGENTS.md"));
210-
doctor_check_hooks(dc, &local_codex_dir.join("hooks.json"));
211208
} else {
212209
doctor_check_plugin(dc, &ctx.home);
213210
}
@@ -228,29 +225,21 @@ impl AgentIntegration for CodexIntegration {
228225
}
229226

230227
fn has_tracedecay(&self, home: &Path) -> bool {
231-
if !codex_plugin_cached_install_dirs(home).is_empty()
228+
!codex_plugin_cached_install_dirs(home).is_empty()
232229
|| codex_plugin_manifest_path(home).exists()
233-
{
234-
return true;
235-
}
236-
Self::has_legacy_config_install(home)
237230
}
238231
}
239232

240-
impl CodexIntegration {
241-
fn has_legacy_config_install(home: &Path) -> bool {
242-
let config = home.join(".codex").join("config.toml");
243-
if !config.exists() {
244-
return false;
245-
}
246-
// If the file is unparseable, conservatively report "not installed"
247-
// so the caller treats it like a fresh install path.
248-
super::load_toml_file(&config).is_ok_and(|toml| {
249-
toml.get("mcp_servers")
250-
.and_then(|v| v.get("tracedecay"))
251-
.is_some()
252-
})
233+
fn codex_legacy_config_has_tracedecay(home: &Path) -> bool {
234+
let config = home.join(".codex").join("config.toml");
235+
if !config.exists() {
236+
return false;
253237
}
238+
super::load_toml_file(&config).is_ok_and(|toml| {
239+
toml.get("mcp_servers")
240+
.and_then(|v| v.get("tracedecay"))
241+
.is_some()
242+
})
254243
}
255244

256245
// ---------------------------------------------------------------------------
@@ -577,6 +566,60 @@ const CODEX_MANAGED_HOOKS: &[CodexManagedHook] = &[
577566
/// the current bundle no longer registers them.
578567
const CODEX_LEGACY_HOOK_SUBCOMMANDS: &[&str] = &["hook-codex-pre-tool-use"];
579568

569+
#[derive(Debug, PartialEq, Eq)]
570+
enum CodexHookTrustState {
571+
Trusted,
572+
Missing(Vec<String>),
573+
}
574+
575+
fn codex_hook_state_event_key(event: &str) -> String {
576+
let mut key = String::new();
577+
for (index, ch) in event.chars().enumerate() {
578+
if ch.is_ascii_uppercase() && index > 0 {
579+
key.push('_');
580+
}
581+
key.push(ch.to_ascii_lowercase());
582+
}
583+
key
584+
}
585+
586+
fn codex_plugin_hook_trust_state(config: &toml::Value) -> CodexHookTrustState {
587+
let Some(state) = config
588+
.get("hooks")
589+
.and_then(|hooks| hooks.get("state"))
590+
.and_then(|state| state.as_table())
591+
else {
592+
return CodexHookTrustState::Missing(
593+
CODEX_MANAGED_HOOKS
594+
.iter()
595+
.map(|hook| codex_hook_state_event_key(hook.event))
596+
.collect(),
597+
);
598+
};
599+
600+
let missing: Vec<String> = CODEX_MANAGED_HOOKS
601+
.iter()
602+
.map(|hook| codex_hook_state_event_key(hook.event))
603+
.filter(|event_key| {
604+
let suffix = format!(":hooks/hooks.json:{event_key}:0:0");
605+
!state.iter().any(|(key, entry)| {
606+
key.starts_with("tracedecay@")
607+
&& key.ends_with(&suffix)
608+
&& entry
609+
.get("trusted_hash")
610+
.and_then(|hash| hash.as_str())
611+
.is_some_and(|hash| hash.starts_with("sha256:"))
612+
})
613+
})
614+
.collect();
615+
616+
if missing.is_empty() {
617+
CodexHookTrustState::Trusted
618+
} else {
619+
CodexHookTrustState::Missing(missing)
620+
}
621+
}
622+
580623
fn codex_plugin_hooks(raw: &str, tracedecay_bin: &str) -> Result<String> {
581624
let mut hooks: serde_json::Value = serde_json::from_str(raw)?;
582625
for hook in CODEX_MANAGED_HOOKS {
@@ -1121,25 +1164,18 @@ fn doctor_check_plugin(dc: &mut DoctorCounters, home: &Path) {
11211164
let cached_dirs = codex_plugin_cached_install_dirs(home);
11221165
if !cached_dirs.is_empty() {
11231166
for plugin_dir in cached_dirs {
1124-
doctor_check_plugin_dir(dc, &plugin_dir);
1167+
doctor_check_plugin_dir(dc, &plugin_dir, Some(&home.join(".codex/config.toml")));
11251168
}
11261169
return;
11271170
}
11281171

11291172
let plugin_dir = codex_plugin_install_dir(home);
11301173
let manifest_path = plugin_dir.join(".codex-plugin/plugin.json");
11311174
if !manifest_path.exists() {
1132-
if CodexIntegration::has_legacy_config_install(home) {
1133-
doctor_check_config(dc, &home.join(".codex/config.toml"));
1134-
dc.warn(
1135-
"Codex uses a legacy config-managed tracedecay install — run `tracedecay install --agent codex` to install the Codex plugin bundle",
1136-
);
1137-
} else {
1138-
dc.warn(&format!(
1139-
"{} not found — run `tracedecay install --agent codex` if you use Codex CLI",
1140-
manifest_path.display()
1141-
));
1142-
}
1175+
dc.warn(&format!(
1176+
"{} not found — run `tracedecay install --agent codex` or `tracedecay update-plugin` to install the Codex plugin bundle",
1177+
manifest_path.display()
1178+
));
11431179
return;
11441180
}
11451181

@@ -1181,7 +1217,11 @@ fn doctor_check_plugin(dc: &mut DoctorCounters, home: &Path) {
11811217
mcp_path.display()
11821218
));
11831219
}
1184-
doctor_check_hooks(dc, &plugin_dir.join("hooks/hooks.json"));
1220+
doctor_check_hooks(
1221+
dc,
1222+
&plugin_dir.join("hooks/hooks.json"),
1223+
Some(&home.join(".codex/config.toml")),
1224+
);
11851225

11861226
doctor_check_marketplace_entry(
11871227
dc,
@@ -1231,7 +1271,7 @@ fn doctor_check_marketplace_entry(
12311271
}
12321272
}
12331273

1234-
fn doctor_check_plugin_dir(dc: &mut DoctorCounters, plugin_dir: &Path) {
1274+
fn doctor_check_plugin_dir(dc: &mut DoctorCounters, plugin_dir: &Path, config_path: Option<&Path>) {
12351275
let manifest_path = plugin_dir.join(".codex-plugin/plugin.json");
12361276
let manifest = load_json_file(&manifest_path);
12371277
if manifest.get("name").and_then(|value| value.as_str()) == Some("tracedecay") {
@@ -1271,95 +1311,12 @@ fn doctor_check_plugin_dir(dc: &mut DoctorCounters, plugin_dir: &Path) {
12711311
mcp_path.display()
12721312
));
12731313
}
1274-
doctor_check_hooks(dc, &plugin_dir.join("hooks/hooks.json"));
1275-
}
1276-
1277-
/// Check config.toml has tracedecay registered.
1278-
fn doctor_check_config(dc: &mut DoctorCounters, config_path: &Path) {
1279-
if !config_path.exists() {
1280-
dc.warn(&format!(
1281-
"{} not found — run `tracedecay install --agent codex` if you use Codex CLI",
1282-
config_path.display()
1283-
));
1284-
return;
1285-
}
1286-
1287-
let config = match load_toml_file(config_path) {
1288-
Ok(c) => c,
1289-
Err(e) => {
1290-
dc.fail(&format!("{e}"));
1291-
return;
1292-
}
1293-
};
1294-
let has_server = config
1295-
.get("mcp_servers")
1296-
.and_then(|v| v.get("tracedecay"))
1297-
.and_then(|v| v.as_table())
1298-
.is_some();
1299-
1300-
if !has_server {
1301-
dc.fail(&format!(
1302-
"MCP server NOT registered in {} — run `tracedecay install --agent codex`",
1303-
config_path.display()
1304-
));
1305-
return;
1306-
}
1307-
dc.pass(&format!(
1308-
"MCP server registered in {}",
1309-
config_path.display()
1310-
));
1311-
1312-
// Check tool auto-approval
1313-
let tools = config
1314-
.get("mcp_servers")
1315-
.and_then(|v| v.get("tracedecay"))
1316-
.and_then(|v| v.get("tools"))
1317-
.and_then(|v| v.as_table());
1318-
1319-
let auto_count = tools.map_or(0, |t| {
1320-
t.values()
1321-
.filter(|v| v.get("approval_mode").and_then(|m| m.as_str()) == Some("auto"))
1322-
.count()
1323-
});
1324-
1325-
let tools = tool_names();
1326-
let tools_len = tools.len();
1327-
if auto_count >= tools_len {
1328-
dc.pass(&format!("All {tools_len} tools set to auto-approve"));
1329-
} else if auto_count > 0 {
1330-
dc.warn(&format!(
1331-
"{auto_count}/{tools_len} tools auto-approved — run `tracedecay install --agent codex` to update"
1332-
));
1333-
} else {
1334-
dc.warn("No tools auto-approved — Codex will prompt for each tool call");
1335-
}
1336-
}
1337-
1338-
/// Check AGENTS.md contains tracedecay rules.
1339-
fn doctor_check_prompt_file(dc: &mut DoctorCounters, agents_md: &Path) {
1340-
if agents_md.exists() {
1341-
let has_rules = std::fs::read_to_string(agents_md)
1342-
.unwrap_or_default()
1343-
.contains("tracedecay");
1344-
if has_rules {
1345-
dc.pass(&format!(
1346-
"AGENTS.md contains tracedecay rules in {}",
1347-
agents_md.display()
1348-
));
1349-
} else {
1350-
dc.fail(&format!(
1351-
"AGENTS.md missing tracedecay rules in {} — run `tracedecay install --local --agent codex` or `tracedecay install --agent codex`",
1352-
agents_md.display()
1353-
));
1354-
}
1355-
} else {
1356-
dc.warn(&format!("{} does not exist", agents_md.display()));
1357-
}
1314+
doctor_check_hooks(dc, &plugin_dir.join("hooks/hooks.json"), config_path);
13581315
}
13591316

1360-
/// Check hooks.json registers the tracedecay lifecycle hooks, and remind the
1361-
/// user that Codex requires trusting them via `/hooks` before they run.
1362-
fn doctor_check_hooks(dc: &mut DoctorCounters, hooks_path: &Path) {
1317+
/// Check hooks.json registers the tracedecay lifecycle hooks, and report Codex
1318+
/// hook trust state when the user-level config is available.
1319+
fn doctor_check_hooks(dc: &mut DoctorCounters, hooks_path: &Path, config_path: Option<&Path>) {
13631320
if !hooks_path.exists() {
13641321
dc.warn(&format!(
13651322
"{} not found — run `tracedecay install --agent codex` to add lifecycle hooks",
@@ -1380,9 +1337,22 @@ fn doctor_check_hooks(dc: &mut DoctorCounters, hooks_path: &Path) {
13801337
CODEX_MANAGED_HOOKS.len(),
13811338
hooks_path.display()
13821339
));
1383-
dc.info(
1384-
"Codex skips new/changed command hooks until trusted — run `/hooks` in Codex to trust the tracedecay hooks",
1385-
);
1340+
match config_path.and_then(|path| load_toml_file(path).ok().map(|config| (path, config))) {
1341+
Some((path, config)) => match codex_plugin_hook_trust_state(&config) {
1342+
CodexHookTrustState::Trusted => dc.info(&format!(
1343+
"Codex hook trust entries recorded in {}",
1344+
path.display()
1345+
)),
1346+
CodexHookTrustState::Missing(missing) => dc.info(&format!(
1347+
"Codex skips new/changed command hooks until trusted — missing trust for {} in {}; run `/hooks` in Codex",
1348+
missing.join(", "),
1349+
path.display()
1350+
)),
1351+
},
1352+
None => dc.info(
1353+
"Codex skips new/changed command hooks until trusted — run `/hooks` in Codex to trust the tracedecay hooks",
1354+
),
1355+
}
13861356
} else {
13871357
dc.warn(&format!(
13881358
"tracedecay hook(s) missing for {} in {} — run `tracedecay install --local --agent codex` or `tracedecay install --agent codex`",
@@ -1478,6 +1448,57 @@ mod tests {
14781448
assert!(!codex_native_memories_injection_enabled(&parse("")));
14791449
}
14801450

1451+
#[test]
1452+
fn codex_hook_trust_state_reports_all_trusted_entries() {
1453+
let config = r#"
1454+
[hooks.state]
1455+
1456+
[hooks.state."tracedecay@personal:hooks/hooks.json:post_tool_use:0:0"]
1457+
trusted_hash = "sha256:post"
1458+
1459+
[hooks.state."tracedecay@personal:hooks/hooks.json:session_start:0:0"]
1460+
trusted_hash = "sha256:session"
1461+
1462+
[hooks.state."tracedecay@personal:hooks/hooks.json:user_prompt_submit:0:0"]
1463+
trusted_hash = "sha256:prompt"
1464+
1465+
[hooks.state."tracedecay@personal:hooks/hooks.json:subagent_start:0:0"]
1466+
trusted_hash = "sha256:subagent"
1467+
1468+
[hooks.state."tracedecay@personal:hooks/hooks.json:post_compact:0:0"]
1469+
trusted_hash = "sha256:compact"
1470+
"#;
1471+
let config = toml::from_str::<toml::Value>(config).unwrap();
1472+
1473+
assert_eq!(
1474+
codex_plugin_hook_trust_state(&config),
1475+
CodexHookTrustState::Trusted
1476+
);
1477+
}
1478+
1479+
#[test]
1480+
fn codex_hook_trust_state_reports_missing_entries() {
1481+
let config = toml::from_str::<toml::Value>(
1482+
r#"
1483+
[hooks.state]
1484+
1485+
[hooks.state."tracedecay@personal:hooks/hooks.json:post_tool_use:0:0"]
1486+
trusted_hash = "sha256:post"
1487+
"#,
1488+
)
1489+
.unwrap();
1490+
1491+
assert_eq!(
1492+
codex_plugin_hook_trust_state(&config),
1493+
CodexHookTrustState::Missing(vec![
1494+
"session_start".to_string(),
1495+
"user_prompt_submit".to_string(),
1496+
"subagent_start".to_string(),
1497+
"post_compact".to_string(),
1498+
])
1499+
);
1500+
}
1501+
14811502
#[test]
14821503
fn remove_legacy_codex_native_automation_deletes_stale_record() {
14831504
let home = tempfile::tempdir().expect("tempdir should create");

0 commit comments

Comments
 (0)