use std::io::{Read as _, Write as _};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use crate::boundary::wire_format::WireFormat;
use crate::core::hook_state::marker::OpenlatchMarker;
use crate::error::ERR_HOOK_NOT_ARMED;
use crate::hooks::codex_cli::{HookTrust, InstalledHandler, ManagedHooksOnly};
use super::super::binding::{
AgentBinding, BindingCapabilities, BoundaryWiring, DaemonChannel, EndpointConvention,
FailureMode, LivenessReport,
};
const EVENT_TYPES: [&str; 12] = [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"UserPromptSubmit",
"SessionStart",
"SessionEnd",
"PreCompact",
"PostCompact",
"Stop",
"SubagentStop",
"SubagentStart",
"Interrupt",
];
pub struct CodexCliBinding {
pub codex_dir: PathBuf,
pub hooks_path: PathBuf,
pub requirements_toml: Option<PathBuf>,
}
impl CodexCliBinding {
pub fn detect() -> Option<Self> {
let codex_dir = crate::hooks::codex_cli::detect()?;
let hooks_path = crate::hooks::codex_cli::hooks_json_path(&codex_dir);
Some(Self {
codex_dir,
hooks_path,
requirements_toml: crate::hooks::codex_cli::requirements_toml_path(),
})
}
}
impl AgentBinding for CodexCliBinding {
fn agent_type(&self) -> &'static str {
"codex-cli"
}
fn display_name(&self) -> &'static str {
"Codex CLI"
}
fn config_dir(&self) -> PathBuf {
self.codex_dir.clone()
}
fn hook_config_path(&self) -> PathBuf {
self.hooks_path.clone()
}
fn hook_event_types(&self) -> &'static [&'static str] {
&EVENT_TYPES
}
fn load_bearing_events(&self) -> &'static [&'static str] {
&["PreToolUse", "PostToolUse", "SessionStart"]
}
fn daemon_channel(&self) -> DaemonChannel {
DaemonChannel::OpenlatchDirArg
}
fn liveness(&self) -> LivenessReport {
let handler = crate::hooks::codex_cli::installed_handler(&self.codex_dir, PROBE_EVENT);
let caveats = vec![
FEATURE_DEFAULT_CAVEAT.to_string(),
PROFILE_CAVEAT.to_string(),
];
let dimensions = [
self.check_binary_resolves(handler.as_ref()),
self.check_trusted(handler.as_ref()),
self.check_not_suppressed(),
];
let mut failure: Option<(String, String)> = None;
let mut unknowns: Vec<String> = Vec::new();
for dimension in dimensions {
match dimension {
Dimension::Ok => {}
Dimension::Failed { detail, remedy } => {
if failure.is_none() {
failure = Some((detail, remedy));
}
}
Dimension::Unobservable(note) => unknowns.push(note),
}
}
if let Some((detail, remedy)) = failure {
return LivenessReport {
armed: Some(false),
detail: Some(sentences(&detail, &unknowns, &caveats)),
remedy: Some(remedy),
code: Some(ERR_HOOK_NOT_ARMED),
};
}
if !unknowns.is_empty() {
return LivenessReport {
armed: None,
detail: Some(sentences(CANNOT_TELL, &unknowns, &caveats)),
remedy: None,
code: None,
};
}
LivenessReport {
armed: Some(true),
detail: Some(sentences(ARMED, &unknowns, &caveats)),
remedy: None,
code: None,
}
}
fn build_hook_entry(
&self,
event: &str,
binary: &Path,
_port: u16,
marker: &OpenlatchMarker,
) -> Value {
let wire_event = crate::hooks::claude_code::pascal_to_snake(event);
let binary_str = binary.display().to_string();
let openlatch_dir = crate::config::openlatch_dir().display().to_string();
let command = format!(
r#""{binary_str}" --agent codex-cli --event {wire_event} --openlatch-dir "{openlatch_dir}""#
);
let timeout = match event {
"SessionEnd" | "Interrupt" => 3,
_ => 10,
};
let hook_inner = json!({
"type": "command",
"command": command,
"timeout": timeout,
});
let marker_value =
serde_json::to_value(marker).expect("OpenlatchMarker is always serializable");
let matcher = match event {
"PreToolUse" => "Bash",
_ => "",
};
json!({
"matcher": matcher,
"_openlatch": marker_value,
"hooks": [hook_inner],
})
}
fn config_is_machine_global(&self) -> bool {
crate::hooks::codex_cli::config_is_machine_global()
}
fn capabilities(&self) -> BindingCapabilities {
BindingCapabilities {
expressible: &["allow", "deny"],
can_mutate_arguments: true,
native_failure_mode: FailureMode::FailOpen,
admin_owned_settings: true,
declares_session_in_request: false,
}
}
fn boundary_wiring(&self) -> Option<BoundaryWiring> {
Some(BoundaryWiring {
wire_format: WireFormat::OpenAiResponses,
endpoint: EndpointConvention::TomlProvider {
provider_name: "openlatch",
wire_api: "responses",
},
install_id_header: "x-openlatch-install-id",
})
}
}
pub const DOCTOR_PROBE_TOOL_NAME: &str = "OpenlatchDoctorProbe";
const PROBE_EVENT: &str = "PreToolUse";
const PROBE_FALLBACK_TIMEOUT_SECS: u64 = 10;
const TRUST_REMEDY: &str = "Run `/hooks` in Codex and trust the OpenLatch hook. \
A re-install that changes the hook command re-arms this review.";
const FEATURE_DEFAULT_CAVEAT: &str =
"An absent `features.*` key is read as no suppression: those keys are absent on a default \
host and their defaults are undocumented, so they are re-verified on each Codex upgrade.";
const PROFILE_CAVEAT: &str =
"A `codex --profile <name>` invocation may override `features.*` from `<name>.config.toml`; \
not observable from `config.toml`.";
const ARMED: &str = "The installed PreToolUse hook answers, Codex records it as trusted, and no \
administrative suppression was observed.";
const CANNOT_TELL: &str = "Codex enforcement could not be proven either way on this host.";
enum Dimension {
Ok,
Failed {
detail: String,
remedy: String,
},
Unobservable(String),
}
impl CodexCliBinding {
fn check_binary_resolves(&self, handler: Option<&InstalledHandler>) -> Dimension {
let Some(handler) = handler else {
return Dimension::Failed {
detail: format!(
"No OpenLatch {PROBE_EVENT} hook is registered in {} — that row is the one \
that carries every deny.",
self.hooks_path.display()
),
remedy: "Run `openlatch init` to install the Codex hooks, or `openlatch doctor \
--fix` to repair an existing install."
.to_string(),
};
};
let timeout =
Duration::from_secs(handler.timeout_secs.unwrap_or(PROBE_FALLBACK_TIMEOUT_SECS));
match run_probe(&handler.command, timeout) {
ProbeOutcome::Answered => Dimension::Ok,
ProbeOutcome::Unspawnable(why) => Dimension::Unobservable(format!(
"The login shell Codex runs hook commands through (`sh -lc`) could not be \
started here ({why}), so the installed command was never exercised."
)),
ProbeOutcome::Bad(why) => Dimension::Failed {
detail: format!(
"The installed {PROBE_EVENT} hook command did not answer: {why}. Codex fails \
open on a hook it cannot run, and says nothing while it does."
),
remedy: format!(
"Restore the hook binary at {} — `openlatch doctor --fix` restages it — then \
re-run `openlatch doctor`.",
binary_from_command(&handler.command)
),
},
}
}
fn check_trusted(&self, handler: Option<&InstalledHandler>) -> Dimension {
let Some(handler) = handler else {
return Dimension::Ok;
};
let config_toml = crate::hooks::codex_cli::config_toml_path(&self.codex_dir);
match crate::hooks::codex_cli::hook_trust(&self.codex_dir, PROBE_EVENT, handler) {
None => Dimension::Unobservable(format!(
"{} could not be read, so what Codex has recorded about trusting this hook is \
unknown.",
config_toml.display()
)),
Some(HookTrust::Trusted) => Dimension::Ok,
Some(HookTrust::NeverTrusted) => Dimension::Failed {
detail: format!(
"Codex has no trusted hash recorded for this hook in {}, so it is marked for \
review and never spawned: installed, correct on disk, and completely inert.",
config_toml.display()
),
remedy: TRUST_REMEDY.to_string(),
},
Some(HookTrust::Disabled) => Dimension::Failed {
detail: format!(
"Codex records this hook as `enabled = false` in {}, so it is never spawned.",
config_toml.display()
),
remedy: TRUST_REMEDY.to_string(),
},
}
}
fn check_not_suppressed(&self) -> Dimension {
let config_toml = crate::hooks::codex_cli::config_toml_path(&self.codex_dir);
if let Some(key) = crate::hooks::codex_cli::suppressing_feature_flag(&self.codex_dir) {
return Dimension::Failed {
detail: format!(
"`features.{key} = false` in {} switches Codex hooks off wholesale.",
config_toml.display()
),
remedy: format!(
"Remove `features.{key} = false` from {} (or set it to `true`), then re-run \
`openlatch doctor`.",
config_toml.display()
),
};
}
match crate::hooks::codex_cli::managed_hooks_only(self.requirements_toml.as_deref()) {
ManagedHooksOnly::Observed(true) => Dimension::Failed {
detail: format!(
"`hooks.allow_managed_hooks_only` is set on this host and {} is a user-level \
source, so Codex drops this hook with no diagnostic output at all.",
self.hooks_path.display()
),
remedy: "Deploy the OpenLatch hook through Codex's managed channel \
(`hooks.managed_dir`, or the system `requirements.toml`), or clear \
`hooks.allow_managed_hooks_only`."
.to_string(),
},
ManagedHooksOnly::Observed(false) => Dimension::Ok,
ManagedHooksOnly::Unobservable => Dimension::Unobservable(
"Codex's system requirements layer could not be consulted here, so \
`hooks.allow_managed_hooks_only` was not observed."
.to_string(),
),
}
}
}
fn sentences(lead: &str, unknowns: &[String], caveats: &[String]) -> String {
std::iter::once(lead)
.chain(unknowns.iter().map(String::as_str))
.chain(caveats.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" ")
}
enum ProbeOutcome {
Answered,
Bad(String),
Unspawnable(String),
}
fn probe_payload() -> Value {
json!({
"hook_event_name": PROBE_EVENT,
"tool_name": DOCTOR_PROBE_TOOL_NAME,
"tool_input": {},
})
}
fn run_probe(command: &str, timeout: Duration) -> ProbeOutcome {
let mut child = match Command::new("sh")
.arg("-lc")
.arg(command)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
{
Ok(child) => child,
Err(e) => return ProbeOutcome::Unspawnable(e.to_string()),
};
let drain = child.stdout.take().map(|mut out| {
std::thread::spawn(move || {
let mut buf = String::new();
let _ = out.read_to_string(&mut buf);
buf
})
});
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(probe_payload().to_string().as_bytes());
}
let deadline = Instant::now() + timeout;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
break None;
}
Ok(None) => std::thread::sleep(Duration::from_millis(10)),
Err(_) => {
let _ = child.kill();
let _ = child.wait();
break None;
}
}
};
let stdout = match status {
Some(_) => drain
.and_then(|handle| handle.join().ok())
.unwrap_or_default(),
None => String::new(),
};
match status {
None => ProbeOutcome::Bad(format!("no response within {}s", timeout.as_secs())),
Some(status) if !status.success() => ProbeOutcome::Bad(match status.code() {
Some(127) => "the command could not be resolved (exit 127)".to_string(),
Some(code) => format!("it exited {code}"),
None => "it was killed by a signal".to_string(),
}),
Some(_) if well_formed_response(&stdout) => ProbeOutcome::Answered,
Some(_) => ProbeOutcome::Bad("its response was not a JSON object".to_string()),
}
}
fn well_formed_response(stdout: &str) -> bool {
let is_object = |s: &str| {
serde_json::from_str::<Value>(s)
.map(|v| v.is_object())
.unwrap_or(false)
};
let trimmed = stdout.trim();
if is_object(trimmed) {
return true;
}
trimmed
.lines()
.rev()
.map(str::trim)
.find(|line| !line.is_empty())
.is_some_and(is_object)
}
fn binary_from_command(command: &str) -> &str {
let trimmed = command.trim_start();
if let Some(rest) = trimmed.strip_prefix('"') {
if let Some(end) = rest.find('"') {
return &rest[..end];
}
}
trimmed.split_whitespace().next().unwrap_or(trimmed)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn binding_at(root: &Path) -> CodexCliBinding {
CodexCliBinding {
codex_dir: root.to_path_buf(),
hooks_path: root.join("hooks.json"),
requirements_toml: Some(root.join("requirements.toml")),
}
}
#[test]
fn codex_home_relocates_the_hooks_path() {
let _lock = crate::hooks::codex_cli::CONFIG_DIR_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let previous = std::env::var_os(crate::hooks::codex_cli::CONFIG_DIR_ENV);
let dir = tempfile::tempdir().expect("temp dir");
std::env::set_var(crate::hooks::codex_cli::CONFIG_DIR_ENV, dir.path());
let b = CodexCliBinding::detect().expect("relocated dir exists, so detect must succeed");
assert_eq!(b.codex_dir, dir.path());
assert_eq!(b.hook_config_path(), dir.path().join("hooks.json"));
std::env::set_var(crate::hooks::codex_cli::CONFIG_DIR_ENV, "");
if let Some(b) = CodexCliBinding::detect() {
assert_ne!(b.codex_dir, Path::new(""));
}
match previous {
Some(v) => std::env::set_var(crate::hooks::codex_cli::CONFIG_DIR_ENV, v),
None => std::env::remove_var(crate::hooks::codex_cli::CONFIG_DIR_ENV),
}
}
#[test]
fn codex_binding_event_list_is_the_twelve_with_pre_tool_use() {
let b = binding_at(Path::new("/home/test/.codex"));
let events = b.hook_event_types();
assert_eq!(events.len(), 12, "all twelve of Codex's native events");
assert!(
events.contains(&"PreToolUse"),
"PreToolUse is the registration — without it Codex is captured, never enforced: \
{events:?}"
);
assert!(events.contains(&"Interrupt"));
assert!(events.contains(&"PostCompact"));
assert!(
b.load_bearing_events().contains(&"PreToolUse"),
"a stripped PreToolUse row must read as unhealthy: {:?}",
b.load_bearing_events()
);
}
#[test]
fn codex_capabilities_cannot_express_ask() {
let b = binding_at(Path::new("/home/test/.codex"));
assert_eq!(b.capabilities().expressible, &["allow", "deny"]);
}
static PROBE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn probe_test_guard() -> std::sync::MutexGuard<'static, ()> {
PROBE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn install_probe_hook(root: &Path) -> InstalledHandler {
std::fs::write(
root.join("hooks.json"),
json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"_openlatch": { "v": 1, "id": "x" },
"hooks": [{
"type": "command",
"command": "printf '{}'",
"timeout": 5,
}],
}],
}
})
.to_string(),
)
.expect("write hooks.json");
crate::hooks::codex_cli::installed_handler(root, PROBE_EVENT)
.expect("the group we just wrote is ours")
}
fn trust_it(root: &Path, handler: &InstalledHandler, extra: &str) {
let key = crate::hooks::codex_cli::trust_key(root, PROBE_EVENT, handler);
std::fs::write(
crate::hooks::codex_cli::config_toml_path(root),
format!("{extra}[hooks.state.'{key}']\ntrusted_hash = \"codex-computed\"\n"),
)
.expect("write config.toml");
}
#[test]
fn codex_untrusted_is_armed_false_with_a_hooks_remedy() {
let _probe_guard = probe_test_guard();
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path();
install_probe_hook(root);
let report = binding_at(root).liveness();
assert_eq!(
report.armed,
Some(false),
"an untrusted hook enforces nothing: {:?}",
report.detail
);
assert_eq!(report.code, Some(ERR_HOOK_NOT_ARMED));
let remedy = report.remedy.expect("a Some(false) MUST carry a remedy");
assert!(
remedy.contains("/hooks"),
"the remedy is the one-time trust step: {remedy}"
);
assert!(
remedy.contains("re-arms"),
"and the trap behind most of these reds — a re-install that changes the command \
re-arms the review: {remedy}"
);
let detail = report.detail.expect("a Some(false) explains itself");
assert!(
detail.contains("trusted hash"),
"the FIRST failing dimension is trust, not the binary: {detail}"
);
}
#[test]
fn codex_trusted_is_armed_true() {
let _probe_guard = probe_test_guard();
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path();
let handler = install_probe_hook(root);
trust_it(root, &handler, "");
let report = binding_at(root).liveness();
assert_eq!(
report.armed,
Some(true),
"all three dimensions hold: {:?}",
report.detail
);
assert_eq!(report.code, None, "a proven-armed report carries no code");
assert_eq!(report.remedy, None, "and nothing to remedy");
}
#[test]
fn managed_only_suppression_is_armed_false() {
let _probe_guard = probe_test_guard();
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path();
let handler = install_probe_hook(root);
trust_it(root, &handler, "");
let requirements = root.join("requirements.toml");
std::fs::write(&requirements, "[hooks]\nallow_managed_hooks_only = true\n")
.expect("write requirements.toml");
let report = CodexCliBinding {
requirements_toml: Some(requirements),
..binding_at(root)
}
.liveness();
assert_eq!(
report.armed,
Some(false),
"a managed-only host drops a user-level hook silently: {:?}",
report.detail
);
assert_eq!(report.code, Some(ERR_HOOK_NOT_ARMED));
let remedy = report.remedy.expect("a Some(false) MUST carry a remedy");
assert!(
remedy.contains("managed_dir") && remedy.contains("requirements.toml"),
"the remedy is managed delivery, not `/hooks`: {remedy}"
);
}
#[test]
fn unobservable_suppression_is_not_reported_as_armed() {
let _probe_guard = probe_test_guard();
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path();
let handler = install_probe_hook(root);
trust_it(root, &handler, "");
let report = CodexCliBinding {
requirements_toml: None,
..binding_at(root)
}
.liveness();
assert_ne!(
report.armed,
Some(true),
"a dimension that was never read cannot report armed: {:?}",
report.detail
);
let detail = report.detail.expect("an unread dimension must say so");
assert!(
detail.contains("allow_managed_hooks_only"),
"the detail must name what could not be observed: {detail}"
);
}
#[test]
fn absent_feature_flag_does_not_block_armed() {
let _probe_guard = probe_test_guard();
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path();
let handler = install_probe_hook(root);
trust_it(root, &handler, "");
let raw = std::fs::read_to_string(crate::hooks::codex_cli::config_toml_path(root))
.expect("read config.toml");
assert!(
!raw.contains("features"),
"the fixture is pointless unless the key really is absent: {raw}"
);
let report = binding_at(root).liveness();
assert_eq!(
report.armed,
Some(true),
"an absent feature flag is `no suppression observed`, never `unproven`: {:?}",
report.detail
);
let detail = report
.detail
.expect("the caveats ride on the check we push");
assert!(
detail.contains("undocumented"),
"the undocumented default is recorded in the text, not in the state: {detail}"
);
assert!(
detail.contains("--profile"),
"and so is what a per-invocation profile could override: {detail}"
);
}
#[test]
fn observed_feature_flag_false_is_armed_false() {
let _probe_guard = probe_test_guard();
let dir = tempfile::tempdir().expect("temp dir");
let root = dir.path();
let handler = install_probe_hook(root);
trust_it(root, &handler, "[features]\nhooks = false\n\n");
let report = binding_at(root).liveness();
assert_eq!(
report.armed,
Some(false),
"`features.hooks = false` switches Codex hooks off: {:?}",
report.detail
);
assert_eq!(report.code, Some(ERR_HOOK_NOT_ARMED));
let remedy = report.remedy.expect("a Some(false) MUST carry a remedy");
assert!(
remedy.contains("features.hooks"),
"the remedy names the key that switched them off: {remedy}"
);
}
#[test]
fn synthetic_probe_uses_a_non_evaluated_tool_name() {
assert_eq!(DOCTOR_PROBE_TOOL_NAME, "OpenlatchDoctorProbe");
assert!(
!crate::core::policy::SHELL_TOOL_NAMES.contains(&DOCTOR_PROBE_TOOL_NAME),
"a probe using an evaluated tool name could match a live policy rule: {:?}",
crate::core::policy::SHELL_TOOL_NAMES
);
let payload = probe_payload();
assert_eq!(payload["tool_name"], DOCTOR_PROBE_TOOL_NAME);
assert_eq!(
payload["tool_input"],
json!({}),
"an inert payload carries no command for anything to act on"
);
assert!(
crate::daemon::handlers::is_doctor_probe(Some(&payload)),
"the daemon must skip this envelope before the audit log"
);
}
#[test]
fn codex_cli_binding_is_arc_dyn_compatible() {
let b = binding_at(Path::new("/tmp/.codex"));
let _arc: Arc<dyn AgentBinding> = Arc::new(b);
}
}