use crate::entry_policy::{create_ccc_auto_entry_payload, create_entry_probe_dry_run_payload};
use crate::{create_session_context, load_runtime_config, summarize_text_for_visibility};
use serde_json::{json, Value};
use std::fmt;
#[derive(Clone, Copy)]
struct HookTier {
id: &'static str,
sentinel_event: &'static str,
lifecycle_point: &'static str,
boundary: &'static str,
affects: &'static [&'static str],
}
const HOOK_TIERS: &[HookTier] = &[
HookTier {
id: "planning",
sentinel_event: "UserPromptSubmit",
lifecycle_point: "before_planning_route",
boundary: "longway_planning",
affects: &["routing"],
},
HookTier {
id: "recovery",
sentinel_event: "Stop",
lifecycle_point: "before_recovery_route",
boundary: "host_subagent_recovery",
affects: &["routing", "verification"],
},
HookTier {
id: "compaction",
sentinel_event: "PreToolUse",
lifecycle_point: "before_context_rollover",
boundary: "codex_cli_session_boundary",
affects: &["routing", "verification"],
},
HookTier {
id: "tool_guard",
sentinel_event: "PreToolUse",
lifecycle_point: "before_tool_mutation",
boundary: "captain_direct_mutation_guard",
affects: &["mutation", "verification"],
},
HookTier {
id: "continuation",
sentinel_event: "UserPromptSubmit",
lifecycle_point: "before_resume_or_advance",
boundary: "active_checkpoint",
affects: &["routing"],
},
HookTier {
id: "fan_in",
sentinel_event: "PostToolUse",
lifecycle_point: "before_fan_in_merge",
boundary: "fan_in_barrier",
affects: &["routing", "verification"],
},
HookTier {
id: "review",
sentinel_event: "PostToolUse",
lifecycle_point: "before_acceptance_gate",
boundary: "arbiter_review",
affects: &["verification"],
},
HookTier {
id: "reporting",
sentinel_event: "PostToolUse",
lifecycle_point: "before_status_projection",
boundary: "operator_status_reporting",
affects: &["verification"],
},
HookTier {
id: "notification",
sentinel_event: "Stop",
lifecycle_point: "after_lifecycle_decision",
boundary: "status_notice",
affects: &["routing", "mutation", "verification"],
},
];
const CODEX_HOOK_EVENT_NAMES: &[&str] = &[
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PreCompact",
"PostCompact",
"SessionStart",
"UserPromptSubmit",
"Stop",
];
const CCC_HOOK_OUTPUT_SCHEMA_EVENTS: &[&str] = &[
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"SessionStart",
"Stop",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HookOutputSchemaViolation {
pub(crate) event: String,
pub(crate) schema: String,
pub(crate) path: String,
pub(crate) reason: String,
}
impl HookOutputSchemaViolation {
fn new(event: &str, schema: &str, path: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
event: event.to_string(),
schema: schema.to_string(),
path: path.into(),
reason: reason.into(),
}
}
}
impl fmt::Display for HookOutputSchemaViolation {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"{} failed {} at {}: {}",
self.event, self.schema, self.path, self.reason
)
}
}
pub(crate) fn create_lifecycle_hook_tiers_payload(
runtime_config: &Value,
current_task_card: &Value,
longway: &Value,
run_truth_surface: &Value,
active_checkpoint: &Value,
recovery_lane: &Value,
long_session_mitigation: &Value,
captain_direct_mutation_guard: &Value,
latest_delegate_result: &Value,
) -> Value {
let hooks_config = runtime_config
.get("lifecycle_hooks")
.filter(|value| value.is_object())
.unwrap_or(&Value::Null);
let globally_enabled = hooks_config
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(true);
let (hook_runner_command, hook_runner_source) = configured_hook_runner_command(hooks_config);
let decisions = HOOK_TIERS
.iter()
.map(|tier| {
create_hook_decision(
tier,
hooks_config,
globally_enabled,
current_task_card,
longway,
run_truth_surface,
active_checkpoint,
recovery_lane,
long_session_mitigation,
captain_direct_mutation_guard,
latest_delegate_result,
)
})
.collect::<Vec<_>>();
let active_tiers = decision_tiers_with_status(&decisions, "decision");
let skipped_tiers = decision_tiers_with_status(&decisions, "skipped");
let failed_tiers = decision_tiers_with_status(&decisions, "failed");
let impacting_decision_count = active_tiers.len();
let failure_count = failed_tiers.len();
json!({
"schema": "ccc.lifecycle_hook_tiers.v1",
"owner": "rust_policy",
"public_commands": false,
"external_host_hook_execution": true,
"hook_runner": {
"command": hook_runner_command,
"command_source": hook_runner_source,
"public_skill": false,
"public_entrypoint": false,
"host_integration_plumbing": true,
"runtime_visibility": "status_locator_bound_when_available"
},
"policy_source": if hooks_config.is_object() { "runtime_config.lifecycle_hooks" } else { "ccc_default_policy" },
"sentinel_event_mapping": sentinel_event_mapping(),
"tiers": hook_tier_definitions(),
"decisions": decisions,
"active_tiers": active_tiers,
"skipped_tiers": skipped_tiers,
"failed_tiers": failed_tiers,
"impacting_decision_count": impacting_decision_count,
"failure_count": failure_count,
"status": if failure_count > 0 {
"failed"
} else if impacting_decision_count > 0 {
"active"
} else {
"clear"
},
})
}
fn configured_hook_runner_command(hooks_config: &Value) -> (String, String) {
let default = "ccc hook run";
if let Some(command) = hooks_config
.pointer("/hook_runner/command")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.filter(|value| is_supported_hook_runner_command(value))
{
return (
command.to_string(),
"runtime_config.lifecycle_hooks.hook_runner.command".to_string(),
);
}
for tier in HOOK_TIERS {
if let Some(command) = hooks_config
.pointer(&format!("/{}/command", tier.id))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.filter(|value| is_supported_hook_runner_command(value))
{
return (
command.to_string(),
format!("runtime_config.lifecycle_hooks.{}.command", tier.id),
);
}
}
(default.to_string(), "ccc_default_policy".to_string())
}
fn create_hook_decision(
tier: &HookTier,
hooks_config: &Value,
globally_enabled: bool,
current_task_card: &Value,
longway: &Value,
run_truth_surface: &Value,
active_checkpoint: &Value,
recovery_lane: &Value,
long_session_mitigation: &Value,
captain_direct_mutation_guard: &Value,
latest_delegate_result: &Value,
) -> Value {
if !globally_enabled {
return hook_decision(
tier,
"skipped",
"disabled_by_policy",
"lifecycle hooks disabled",
);
}
if let Some(failure) = config_failure_decision(tier, hooks_config) {
return failure;
}
match tier.id {
"planning" => planning_decision(tier, longway),
"recovery" => recovery_decision(tier, recovery_lane),
"compaction" => compaction_decision(tier, long_session_mitigation),
"tool_guard" => tool_guard_decision(tier, captain_direct_mutation_guard),
"continuation" => continuation_decision(tier, active_checkpoint),
"fan_in" => fan_in_decision(tier, run_truth_surface),
"review" => review_decision(tier, current_task_card),
"reporting" => reporting_decision(tier, current_task_card, latest_delegate_result),
"notification" => notification_decision(
tier,
recovery_lane,
long_session_mitigation,
captain_direct_mutation_guard,
),
_ => hook_decision(tier, "skipped", "unknown_tier", "tier is not recognized"),
}
}
fn config_failure_decision(tier: &HookTier, hooks_config: &Value) -> Option<Value> {
let tier_config = hooks_config.get(tier.id)?;
if let Some(enabled) = tier_config.get("enabled") {
if enabled.as_bool() == Some(false) {
return Some(hook_decision(
tier,
"skipped",
"disabled_by_policy",
"tier disabled by lifecycle hook policy",
));
}
if !enabled.is_boolean() {
return Some(hook_decision(
tier,
"failed",
"invalid_policy",
"`enabled` must be a boolean",
));
}
}
if let Some(command) = tier_config.get("command").and_then(Value::as_str) {
if !is_supported_hook_runner_command(command) {
return Some(hook_decision(
tier,
"failed",
"unsupported_hook_runner_command",
"only the internal `ccc hook run` host integration runner is supported",
));
}
}
if tier_config.get("commands").is_some() {
return Some(hook_decision(
tier,
"failed",
"multi_hook_commands_unsupported",
"multiple hook commands are not supported",
));
}
None
}
fn planning_decision(tier: &HookTier, longway: &Value) -> Value {
let lifecycle_state = text_value(longway, "/lifecycle_state").unwrap_or("unknown");
let active_status = text_value(longway, "/active_phase_status").unwrap_or("unknown");
if matches!(lifecycle_state, "planned" | "planning" | "active")
|| matches!(active_status, "pending_longway_approval" | "in_progress")
{
return hook_decision(
tier,
"decision",
"route_planning_boundary",
"LongWay planning boundary affects routing",
);
}
hook_decision(
tier,
"skipped",
"no_planning_boundary",
"no planning route change",
)
}
fn recovery_decision(tier: &HookTier, recovery_lane: &Value) -> Value {
let status = text_value(recovery_lane, "/status").unwrap_or("clear");
let action = text_value(recovery_lane, "/recommended_action").unwrap_or("none");
if matches!(status, "recovery_pending" | "reclaim_pending") || action != "none" {
return hook_decision(
tier,
"decision",
action,
"recovery lane changed the next routing action",
);
}
hook_decision(
tier,
"skipped",
"no_recovery_action",
"recovery lane is clear",
)
}
fn compaction_decision(tier: &HookTier, long_session_mitigation: &Value) -> Value {
if long_session_mitigation
.get("recommended")
.and_then(Value::as_bool)
.unwrap_or(false)
{
let action =
text_value(long_session_mitigation, "/recommended_action").unwrap_or("/compact");
return hook_decision(
tier,
"decision",
action,
"context pressure requires checkpoint before Codex CLI rollover",
);
}
hook_decision(
tier,
"skipped",
"no_context_pressure",
"compaction boundary is clear",
)
}
fn tool_guard_decision(tier: &HookTier, guard: &Value) -> Value {
let state = text_value(guard, "/state").unwrap_or("unknown");
if matches!(
state,
"blocked_unrecorded_direct_mutation" | "exception_recorded"
) {
let mut decision = hook_decision(
tier,
"decision",
state,
"mutation guard affects file mutation or verification routing",
);
if let Some(object) = decision.as_object_mut() {
object.insert(
"required_action".to_string(),
guard.get("required_action").cloned().unwrap_or(Value::Null),
);
object.insert(
"enforcement".to_string(),
Value::String("sentinel_direct_mutation_guard".to_string()),
);
}
return decision;
}
hook_decision(
tier,
"skipped",
"mutation_guard_clear",
"no mutation guard route change",
)
}
fn continuation_decision(tier: &HookTier, active_checkpoint: &Value) -> Value {
if !active_checkpoint.is_object() {
return hook_decision(
tier,
"skipped",
"no_active_checkpoint",
"no active run checkpoint",
);
}
let resume_action = text_value(active_checkpoint, "/resume_action").unwrap_or("advance");
hook_decision(
tier,
"decision",
resume_action,
"active checkpoint owns continuation routing",
)
}
fn fan_in_decision(tier: &HookTier, run_truth_surface: &Value) -> Value {
if run_truth_surface
.get("fan_in_ready")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return hook_decision(
tier,
"decision",
"fan_in_ready",
"fan-in barrier is ready for captain merge/review",
);
}
hook_decision(
tier,
"skipped",
"fan_in_not_ready",
"fan-in barrier is not ready",
)
}
fn review_decision(tier: &HookTier, current_task_card: &Value) -> Value {
if current_task_card.get("review_policy").is_some()
|| current_task_card.get("review_fan_in").is_some()
|| current_task_card.get("orchestrator_review_gate").is_some()
{
return hook_decision(
tier,
"decision",
"review_boundary_present",
"review policy or fan-in affects verification",
);
}
hook_decision(
tier,
"skipped",
"no_review_boundary",
"no review boundary is active",
)
}
fn reporting_decision(
tier: &HookTier,
current_task_card: &Value,
latest_delegate_result: &Value,
) -> Value {
if latest_delegate_result.is_object()
|| current_task_card.get("subagent_fan_in").is_some()
|| current_task_card.get("review_fan_in").is_some()
{
return hook_decision(
tier,
"decision",
"report_status_update",
"fan-in or delegate result affects verification visibility",
);
}
hook_decision(
tier,
"skipped",
"nothing_to_report",
"no reporting route change",
)
}
fn notification_decision(
tier: &HookTier,
recovery_lane: &Value,
long_session_mitigation: &Value,
captain_direct_mutation_guard: &Value,
) -> Value {
let recovery_attention = recovery_lane
.get("needs_operator_attention")
.and_then(Value::as_bool)
.unwrap_or(false);
let rollover_attention = long_session_mitigation
.get("operator_choice_required")
.and_then(Value::as_bool)
.unwrap_or(false);
let guard_blocked = text_value(captain_direct_mutation_guard, "/state")
== Some("blocked_unrecorded_direct_mutation");
if recovery_attention || rollover_attention || guard_blocked {
return hook_decision(
tier,
"decision",
"emit_status_notice",
"operator-visible status notice is required",
);
}
hook_decision(
tier,
"skipped",
"no_notice_required",
"no notification-affecting event",
)
}
fn hook_decision(tier: &HookTier, status: &str, action: &str, reason: &str) -> Value {
json!({
"tier": tier.id,
"status": status,
"guardrail_decision": guardrail_decision(status),
"action": action,
"reason": reason,
"sentinel_event": tier.sentinel_event,
"lifecycle_point": tier.lifecycle_point,
"boundary": tier.boundary,
"affects": tier.affects,
})
}
fn hook_tier_definitions() -> Value {
Value::Array(
HOOK_TIERS
.iter()
.map(|tier| {
json!({
"tier": tier.id,
"sentinel_event": tier.sentinel_event,
"lifecycle_point": tier.lifecycle_point,
"boundary": tier.boundary,
"affects": tier.affects,
"owner": "rust_policy",
"public_command": false,
"external_host_hook_execution": true,
})
})
.collect(),
)
}
fn sentinel_event_mapping() -> Value {
Value::Array(
["UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"]
.iter()
.map(|event| {
let tiers = HOOK_TIERS
.iter()
.filter(|tier| tier.sentinel_event == *event)
.map(|tier| tier.id)
.collect::<Vec<_>>();
let boundaries = HOOK_TIERS
.iter()
.filter(|tier| tier.sentinel_event == *event)
.map(|tier| tier.boundary)
.collect::<Vec<_>>();
let affects = HOOK_TIERS
.iter()
.filter(|tier| tier.sentinel_event == *event)
.flat_map(|tier| tier.affects.iter().copied())
.fold(Vec::<&'static str>::new(), |mut acc, value| {
if !acc.contains(&value) {
acc.push(value);
}
acc
});
json!({
"event": event,
"style": "sentinel_overseer_guardrail",
"tiers": tiers,
"boundaries": boundaries,
"affects": affects,
"owner": "rust_policy",
"public_command": false,
"external_host_hook_execution": true,
})
})
.collect(),
)
}
pub(crate) fn is_supported_hook_runner_command(command: &str) -> bool {
let normalized = command.split_whitespace().collect::<Vec<_>>();
normalized.len() >= 3
&& normalized[0] == "ccc"
&& normalized[1] == "hook"
&& normalized[2] == "run"
&& !normalized
.iter()
.any(|token| matches!(*token, "&&" | "||" | "|" | ";"))
}
pub(crate) fn create_lifecycle_hook_run_payload(arguments: &Value) -> Value {
let event = arguments
.get("event")
.and_then(Value::as_str)
.or_else(|| arguments.get("sentinel_event").and_then(Value::as_str))
.or_else(|| arguments.get("hook_event_name").and_then(Value::as_str))
.unwrap_or("UserPromptSubmit");
let runtime_config = merged_hook_runtime_config(arguments);
let current_task_card = arguments
.get("current_task_card")
.cloned()
.unwrap_or(Value::Null);
let longway = arguments.get("longway").cloned().unwrap_or(Value::Null);
let run_truth_surface = arguments
.get("run_truth_surface")
.cloned()
.unwrap_or_else(|| json!({ "fan_in_ready": false }));
let active_checkpoint = arguments
.get("active_checkpoint")
.cloned()
.unwrap_or(Value::Null);
let recovery_lane = arguments
.get("recovery_lane")
.cloned()
.unwrap_or(Value::Null);
let long_session_mitigation = arguments
.get("long_session_mitigation")
.cloned()
.unwrap_or(Value::Null);
let captain_direct_mutation_guard = arguments
.get("captain_direct_mutation_guard")
.cloned()
.unwrap_or(Value::Null);
let latest_delegate_result = arguments
.get("latest_delegate_result")
.cloned()
.unwrap_or(Value::Null);
let all = create_lifecycle_hook_tiers_payload(
&runtime_config,
¤t_task_card,
&longway,
&run_truth_surface,
&active_checkpoint,
&recovery_lane,
&long_session_mitigation,
&captain_direct_mutation_guard,
&latest_delegate_result,
);
let decisions = all
.get("decisions")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter(|decision| {
decision.get("sentinel_event").and_then(Value::as_str) == Some(event)
})
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
let active_tiers = decision_tiers_with_status(&decisions, "decision");
let failed_tiers = decision_tiers_with_status(&decisions, "failed");
let entry_probe = create_hook_entry_probe(event, arguments);
json!({
"schema": "ccc.lifecycle_hook_run.v1",
"event": event,
"owner": "rust_policy",
"public_skill": false,
"public_entrypoint": false,
"host_integration_plumbing": true,
"external_host_hook_execution": true,
"hook_runner": all.get("hook_runner").cloned().unwrap_or(Value::Null),
"run_locator": {
"cwd": arguments.get("cwd").cloned().unwrap_or(Value::Null),
"run_id": arguments.get("run_id").cloned().unwrap_or(Value::Null),
"run_ref": arguments.get("run_ref").cloned().unwrap_or(Value::Null),
"run_directory": arguments.get("run_directory").or_else(|| arguments.get("run_dir")).cloned().unwrap_or(Value::Null)
},
"status": if !failed_tiers.is_empty() {
"failed"
} else if !active_tiers.is_empty() {
"decision"
} else {
"clear"
},
"active_tiers": active_tiers,
"failed_tiers": failed_tiers,
"entry_probe": entry_probe,
"decisions": decisions,
"sentinel_event_mapping": all.get("sentinel_event_mapping").cloned().unwrap_or(Value::Null),
"summary": format!("CCC Sentinel lifecycle hook evaluation completed for {event}.")
})
}
fn create_hook_entry_probe(event: &str, arguments: &Value) -> Value {
let request = arguments
.get("prompt")
.or_else(|| arguments.get("user_prompt"))
.or_else(|| arguments.get("request"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let hook_event_supported = event == "UserPromptSubmit";
if !hook_event_supported {
return json!({
"schema": "ccc.hook_entry_probe.v1",
"status": "not_applicable",
"hook_event_supported": false,
"activation_path": "plugin_hook_user_prompt_submit",
"candidate": false,
"ignored": true,
"reason": "unsupported_hook_event",
"confidence": "high",
"entry_command": "ccc auto-entry --quiet --json '{...}'",
"entry_command_supported": true,
"would_invoke_ccc_auto_entry": false,
"mutates_on_hook": false,
"preserves_cap_skill_path": true,
"public_entry_label": "$cap",
"summary": "Entry probe only applies to UserPromptSubmit and remains non-mutating."
});
}
let runtime_config = merged_hook_runtime_config(arguments);
let dry_run = create_entry_probe_dry_run_payload(
request,
&runtime_config,
"plugin_hook_user_prompt_submit",
);
let auto_entry = maybe_create_hook_auto_entry(request, &runtime_config, arguments, &dry_run);
let auto_entry_outcome = auto_entry
.get("outcome")
.and_then(Value::as_str)
.unwrap_or("ignored");
json!({
"schema": "ccc.hook_entry_probe.v1",
"status": if auto_entry_outcome == "created" {
"created"
} else if request.is_none() {
"waiting_for_prompt"
} else {
dry_run.get("status").and_then(Value::as_str).unwrap_or("ignored")
},
"auto_entry_outcome": auto_entry_outcome,
"hook_event_supported": true,
"activation_path": "plugin_hook_user_prompt_submit",
"candidate": dry_run.get("candidate").cloned().unwrap_or(Value::Bool(false)),
"ignored": dry_run.get("ignored").cloned().unwrap_or(Value::Bool(true)),
"reason": dry_run.get("reason").cloned().unwrap_or(Value::String("unknown".to_string())),
"confidence": dry_run.get("confidence").cloned().unwrap_or(Value::String("low".to_string())),
"candidate_kind": dry_run.get("candidate_kind").cloned().unwrap_or(Value::Null),
"request_shape": dry_run.get("request_shape").cloned().unwrap_or(Value::Null),
"task_shape": dry_run.get("task_shape").cloned().unwrap_or(Value::Null),
"opt_in": dry_run.get("opt_in").cloned().unwrap_or(Value::Null),
"entry_command": "ccc auto-entry --quiet --json '{...}'",
"entry_command_supported": true,
"would_invoke_ccc_auto_entry": dry_run.get("would_invoke_ccc_auto_entry").cloned().unwrap_or(Value::Bool(false)),
"mutates_on_hook": auto_entry_outcome == "created",
"preserves_cap_skill_path": true,
"public_entry_label": "$cap",
"dry_run": dry_run,
"auto_entry": auto_entry,
"summary": match auto_entry_outcome {
"created" => "Hook entry probe invoked opt-in ccc auto-entry and created a bounded run; $cap remains supported.",
"requires_opt_in" => "Hook entry probe found a candidate but did not create a run because non-$cap auto-entry is not opted in.",
"ignored" => "Hook entry probe ignored this prompt under the conservative non-$cap policy.",
_ => "Hook entry probe completed without creating a run; ccc auto-entry remains opt-in and $cap remains supported.",
}
})
}
fn merged_hook_runtime_config(arguments: &Value) -> Value {
let payload_config = arguments
.get("runtime_config")
.or_else(|| arguments.get("config"))
.cloned();
let shared_config = load_runtime_config().ok();
match (shared_config, payload_config) {
(Some(mut shared), Some(payload)) => {
merge_json_overlay(&mut shared, &payload);
shared
}
(Some(shared), None) => shared,
(None, Some(payload)) => payload,
(None, None) => json!({}),
}
}
fn merge_json_overlay(base: &mut Value, overlay: &Value) {
match (base, overlay) {
(Value::Object(base_object), Value::Object(overlay_object)) => {
for (key, value) in overlay_object {
match base_object.get_mut(key) {
Some(existing) => merge_json_overlay(existing, value),
None => {
base_object.insert(key.clone(), value.clone());
}
}
}
}
(base_value, overlay_value) => {
*base_value = overlay_value.clone();
}
}
}
fn maybe_create_hook_auto_entry(
request: Option<&str>,
runtime_config: &Value,
arguments: &Value,
dry_run: &Value,
) -> Value {
let candidate = dry_run
.get("candidate")
.and_then(Value::as_bool)
.unwrap_or(false);
if !candidate {
return hook_auto_entry_summary("ignored", "entry_probe_ignored", Value::Null);
}
let would_invoke = dry_run
.get("would_invoke_ccc_auto_entry")
.and_then(Value::as_bool)
.unwrap_or(false);
if !would_invoke {
return hook_auto_entry_summary("requires_opt_in", "auto_entry_not_opted_in", Value::Null);
}
let Some(request) = request else {
return hook_auto_entry_summary("ignored", "no_prompt", Value::Null);
};
let Some(cwd) = arguments
.get("cwd")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return hook_auto_entry_summary("ignored", "missing_cwd", Value::Null);
};
let parsed = json!({
"cwd": cwd,
"request": request,
"codex_bin": arguments.get("codex_bin").cloned().unwrap_or(Value::Null),
"entry_policy": runtime_config.get("entry_policy").cloned().unwrap_or(Value::Null),
});
let session_context = create_session_context();
match create_ccc_auto_entry_payload(&session_context, &parsed) {
Ok(payload) => {
let outcome = if payload.get("created").and_then(Value::as_bool) == Some(true) {
"created"
} else {
match payload.get("run_selection").and_then(Value::as_str) {
Some("explicit_entry_required") => "requires_opt_in",
Some("entry_probe_ignored") => "ignored",
Some("operator_confirmation_required") => "requires_confirmation",
_ => "ignored",
}
};
let reason = payload
.get("run_selection")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
hook_auto_entry_summary(outcome, &reason, payload)
}
Err(error) => hook_auto_entry_summary(
"failed",
"auto_entry_error",
json!({ "error": error.to_string() }),
),
}
}
fn hook_auto_entry_summary(outcome: &str, reason: &str, payload: Value) -> Value {
let current_task = payload
.get("current_task_card")
.filter(|value| value.is_object())
.map(|task| {
json!({
"task_card_id": task.get("task_card_id").cloned().unwrap_or(Value::Null),
"title": task.get("title").cloned().unwrap_or(Value::Null),
"assigned_agent_id": task.get("assigned_agent_id").cloned().unwrap_or(Value::Null),
"assigned_role": task.get("assigned_role").cloned().unwrap_or(Value::Null),
"status": task.get("status").cloned().unwrap_or(Value::Null),
})
})
.unwrap_or(Value::Null);
let dispatch_next = if payload.is_object() {
json!({
"next_step": payload.get("next_step").cloned().unwrap_or(Value::Null),
"can_advance": payload.get("can_advance").cloned().unwrap_or(Value::Null),
"allowed_next_commands": payload.get("allowed_next_commands").cloned().unwrap_or(Value::Null),
})
} else {
Value::Null
};
json!({
"schema": "ccc.hook_auto_entry_result.v1",
"outcome": outcome,
"reason": reason,
"created": payload.get("created").and_then(Value::as_bool).unwrap_or(false),
"run_selection": payload.get("run_selection").cloned().unwrap_or(Value::Null),
"run_id": payload.get("run_id").cloned().unwrap_or(Value::Null),
"run_ref": payload.get("run_ref").cloned().unwrap_or(Value::Null),
"run_directory": payload.get("run_directory").cloned().unwrap_or(Value::Null),
"dispatch_next": dispatch_next,
"current_task": current_task,
"summary": payload
.get("summary")
.and_then(Value::as_str)
.map(|value| summarize_text_for_visibility(value, 180))
.unwrap_or_else(|| match outcome {
"created" => "Opt-in hook auto-entry created a bounded run.".to_string(),
"requires_opt_in" => "A qualifying prompt requires explicit non-$cap auto-entry opt-in before run creation.".to_string(),
"ignored" => "Hook auto-entry did not create a run.".to_string(),
"requires_confirmation" => "Hook auto-entry requires operator confirmation before run creation.".to_string(),
_ => "Hook auto-entry did not complete run creation.".to_string(),
}),
"payload": if payload.is_object() { payload } else { Value::Null },
})
}
pub(crate) fn create_lifecycle_hook_command_output_checked(
payload: &Value,
) -> Result<Option<Value>, HookOutputSchemaViolation> {
let output = create_lifecycle_hook_command_output_unchecked(payload);
if let Some(output) = &output {
let event = payload
.get("event")
.and_then(Value::as_str)
.unwrap_or("UserPromptSubmit");
validate_lifecycle_hook_command_output(event, output)?;
}
Ok(output)
}
#[cfg(test)]
pub(crate) fn create_lifecycle_hook_command_output(payload: &Value) -> Option<Value> {
create_lifecycle_hook_command_output_unchecked(payload)
}
fn create_lifecycle_hook_command_output_unchecked(payload: &Value) -> Option<Value> {
let event = payload
.get("event")
.and_then(Value::as_str)
.unwrap_or("UserPromptSubmit");
let status = payload
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown");
let active_count = payload
.get("active_tiers")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
let failed_count = payload
.get("failed_tiers")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
let surface_entry_probe_on_clear = hook_entry_probe_surfaces_on_clear(event, payload);
if status == "clear" && active_count == 0 && failed_count == 0 && !surface_entry_probe_on_clear
{
return None;
}
let entry_context = payload
.get("entry_probe")
.filter(|value| value.is_object())
.map(|probe| create_user_prompt_submit_entry_context(probe))
.unwrap_or_default();
let entry_context_suffix = if entry_context.is_empty() {
String::new()
} else {
format!(" {entry_context}")
};
let decision_context = hook_decision_context(payload);
if event == "UserPromptSubmit"
&& status == "clear"
&& active_count == 0
&& failed_count == 0
&& surface_entry_probe_on_clear
{
return Some(json!({
"hookSpecificOutput": {
"hookEventName": event,
"additionalContext": format!(
"CCC UserPromptSubmit auto-entry completed: {entry_context}"
)
}
}));
}
let message = format!(
"CCC lifecycle hook evaluated {event}: status={status}, active_tiers={active_count}.{entry_context_suffix}{decision_context}"
);
Some(match event {
"SessionStart" | "UserPromptSubmit" | "PostToolUse" | "PreToolUse" => json!({
"systemMessage": message,
"hookSpecificOutput": {
"hookEventName": event,
"additionalContext": message
}
}),
_ => json!({
"systemMessage": message
}),
})
}
pub(crate) fn create_lifecycle_hook_schema_compatibility_payload() -> Value {
let checks = CCC_HOOK_OUTPUT_SCHEMA_EVENTS
.iter()
.map(|event| create_hook_schema_compatibility_check(event))
.collect::<Vec<_>>();
let failed = checks
.iter()
.filter(|check| check.get("status").and_then(Value::as_str) != Some("pass"))
.collect::<Vec<_>>();
json!({
"schema": "ccc.lifecycle_hook_schema_compatibility.v1",
"status": if failed.is_empty() { "pass" } else { "fail" },
"validator": "rust_focused_codex_hook_output_contract",
"validator_scope": "focused_runtime_semantics_not_full_json_schema",
"contract_source": "local_0.131_0.132_release_docs_plus_codex_hook_runtime_semantics",
"codex_cli_contract_review": {
"reviewed_versions": ["0.131", "0.132"],
"plugin_hooks_default_enabled": false,
"plugin_hooks_require_feature_opt_in": true,
"hook_trust_session_id_behavior": "plugin_hook_state_key_and_trusted_hash_are_session_config_gates",
"pre_tool_use_updated_input": "allowed_only_with_permissionDecision_allow",
"additional_context_spill": "additionalContext is allowed only inside hookSpecificOutput for supported events",
"async_extension_lifecycle": "CCC treats async or extension-host behavior as host-owned and keeps CLI/MCP/status fallback active",
},
"blocking_for_release": true,
"checked_event_count": checks.len(),
"failed_event_count": failed.len(),
"events": CCC_HOOK_OUTPUT_SCHEMA_EVENTS,
"schema_names": CCC_HOOK_OUTPUT_SCHEMA_EVENTS
.iter()
.filter_map(|event| codex_hook_output_schema_name(event))
.collect::<Vec<_>>(),
"checks": checks,
"summary": if failed.is_empty() {
"CCC lifecycle hook command outputs conform to the current focused Codex hook runtime output contracts."
} else {
"One or more CCC lifecycle hook command outputs violate the current focused Codex hook runtime output contracts."
}
})
}
fn create_hook_schema_compatibility_check(event: &str) -> Value {
let schema_name = codex_hook_output_schema_name(event).unwrap_or("unsupported");
let fixture_payload = hook_schema_compatibility_fixture_payload(event);
let output = create_lifecycle_hook_command_output_unchecked(&fixture_payload);
match output {
Some(output) => match validate_lifecycle_hook_command_output(event, &output) {
Ok(()) => json!({
"event": event,
"output_schema": schema_name,
"status": "pass",
"output_present": true,
"validated_contract": "focused_runtime_semantics_not_full_json_schema",
"summary": format!("{event} CCC hook output conforms to {schema_name}.")
}),
Err(violation) => json!({
"event": event,
"output_schema": schema_name,
"status": "fail",
"output_present": true,
"violation": {
"path": violation.path,
"reason": violation.reason,
},
"summary": format!("{event} CCC hook output violates {schema_name}.")
}),
},
None => json!({
"event": event,
"output_schema": schema_name,
"status": "fail",
"output_present": false,
"violation": {
"path": "/",
"reason": "compatibility fixture produced no hook command output",
},
"summary": format!("{event} CCC hook output could not be produced for {schema_name}.")
}),
}
}
fn hook_schema_compatibility_fixture_payload(event: &str) -> Value {
json!({
"schema": "ccc.lifecycle_hook_run.v1",
"event": event,
"status": "decision",
"active_tiers": ["schema_compatibility"],
"failed_tiers": [],
"entry_probe": Value::Null,
"decisions": [],
})
}
pub(crate) fn validate_lifecycle_hook_command_output(
event: &str,
output: &Value,
) -> Result<(), HookOutputSchemaViolation> {
let Some(schema_name) = codex_hook_output_schema_name(event) else {
return Err(HookOutputSchemaViolation::new(
event,
"unsupported",
"/",
"unsupported hook output event",
));
};
let Some(object) = output.as_object() else {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
"/",
"hook output must be a JSON object",
));
};
let allowed_top_level = allowed_hook_output_top_level_fields(event);
for key in object.keys() {
if !allowed_top_level.contains(&key.as_str()) {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
format!("/{key}"),
"field is not declared by the Codex hook output schema",
));
}
}
validate_optional_bool_field(event, schema_name, object.get("continue"), "/continue")?;
validate_optional_bool_field(
event,
schema_name,
object.get("suppressOutput"),
"/suppressOutput",
)?;
validate_optional_string_field(event, schema_name, object.get("stopReason"), "/stopReason")?;
validate_optional_string_field(
event,
schema_name,
object.get("systemMessage"),
"/systemMessage",
)?;
validate_hook_output_decision(event, schema_name, object.get("decision"))?;
validate_optional_string_field(event, schema_name, object.get("reason"), "/reason")?;
validate_event_runtime_semantics(event, schema_name, object)?;
validate_hook_specific_output(event, schema_name, object.get("hookSpecificOutput"))?;
Ok(())
}
fn codex_hook_output_schema_name(event: &str) -> Option<&'static str> {
match event {
"UserPromptSubmit" => Some("user-prompt-submit.command.output.schema.json"),
"PreToolUse" => Some("pre-tool-use.command.output.schema.json"),
"PostToolUse" => Some("post-tool-use.command.output.schema.json"),
"SessionStart" => Some("session-start.command.output.schema.json"),
"Stop" => Some("stop.command.output.schema.json"),
_ => None,
}
}
fn allowed_hook_output_top_level_fields(event: &str) -> &'static [&'static str] {
match event {
"UserPromptSubmit" => &[
"continue",
"decision",
"hookSpecificOutput",
"reason",
"stopReason",
"suppressOutput",
"systemMessage",
],
"PreToolUse" => &["decision", "hookSpecificOutput", "reason", "systemMessage"],
"PostToolUse" => &[
"continue",
"decision",
"hookSpecificOutput",
"reason",
"stopReason",
"systemMessage",
],
"SessionStart" => &[
"continue",
"hookSpecificOutput",
"stopReason",
"suppressOutput",
"systemMessage",
],
"Stop" => &[
"continue",
"decision",
"reason",
"stopReason",
"suppressOutput",
"systemMessage",
],
_ => &[],
}
}
fn allowed_hook_specific_output_fields(event: &str) -> &'static [&'static str] {
match event {
"UserPromptSubmit" | "SessionStart" => &["additionalContext", "hookEventName"],
"PreToolUse" => &[
"additionalContext",
"hookEventName",
"permissionDecision",
"permissionDecisionReason",
"updatedInput",
],
"PostToolUse" => &["additionalContext", "hookEventName"],
_ => &[],
}
}
fn validate_optional_bool_field(
event: &str,
schema_name: &str,
value: Option<&Value>,
path: &str,
) -> Result<(), HookOutputSchemaViolation> {
if value.is_some_and(|value| !value.is_boolean()) {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
path,
"field must be a boolean",
));
}
Ok(())
}
fn validate_optional_string_field(
event: &str,
schema_name: &str,
value: Option<&Value>,
path: &str,
) -> Result<(), HookOutputSchemaViolation> {
if value.is_some_and(|value| !value.is_string()) {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
path,
"field must be a string",
));
}
Ok(())
}
fn require_non_empty_string_field(
event: &str,
schema_name: &str,
value: Option<&Value>,
path: &str,
reason: &str,
) -> Result<(), HookOutputSchemaViolation> {
let Some(value) = value else {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
path,
reason,
));
};
let Some(text) = value.as_str() else {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
path,
"field must be a string",
));
};
if text.trim().is_empty() {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
path,
reason,
));
}
Ok(())
}
fn validate_event_runtime_semantics(
event: &str,
schema_name: &str,
object: &serde_json::Map<String, Value>,
) -> Result<(), HookOutputSchemaViolation> {
if event == "PostToolUse"
&& object
.get("continue")
.and_then(Value::as_bool)
.is_some_and(|value| value)
{
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
"/continue",
"PostToolUse only supports continue: false",
));
}
if object.get("decision").and_then(Value::as_str) == Some("block") {
require_non_empty_string_field(
event,
schema_name,
object.get("reason"),
"/reason",
"block decision requires a non-empty reason",
)?;
}
Ok(())
}
fn validate_hook_output_decision(
event: &str,
schema_name: &str,
value: Option<&Value>,
) -> Result<(), HookOutputSchemaViolation> {
let Some(value) = value else {
return Ok(());
};
let Some(decision) = value.as_str() else {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
"/decision",
"decision must be a string",
));
};
let allowed = match event {
"PreToolUse" => &["block"][..],
"UserPromptSubmit" | "PostToolUse" | "Stop" => &["block"][..],
_ => &[][..],
};
if !allowed.contains(&decision) {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
"/decision",
format!("decision must be one of {}", allowed.join(", ")),
));
}
Ok(())
}
fn validate_hook_specific_output(
event: &str,
schema_name: &str,
value: Option<&Value>,
) -> Result<(), HookOutputSchemaViolation> {
let Some(value) = value else {
return Ok(());
};
let allowed_fields = allowed_hook_specific_output_fields(event);
if allowed_fields.is_empty() {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
"/hookSpecificOutput",
"hookSpecificOutput is not declared by this Codex hook output schema",
));
}
let Some(object) = value.as_object() else {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
"/hookSpecificOutput",
"hookSpecificOutput must be an object",
));
};
for key in object.keys() {
if !allowed_fields.contains(&key.as_str()) {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
format!("/hookSpecificOutput/{key}"),
"field is not declared by the Codex hook-specific output schema",
));
}
}
let Some(hook_event_name) = object.get("hookEventName").and_then(Value::as_str) else {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
"/hookSpecificOutput/hookEventName",
"hookEventName is required and must be a string",
));
};
if !CODEX_HOOK_EVENT_NAMES.contains(&hook_event_name) {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
"/hookSpecificOutput/hookEventName",
"hookEventName is not a current Codex hook event name",
));
}
if hook_event_name != event {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
"/hookSpecificOutput/hookEventName",
"hookEventName must match the hook output event",
));
}
validate_optional_string_field(
event,
schema_name,
object.get("additionalContext"),
"/hookSpecificOutput/additionalContext",
)?;
validate_optional_object_field(
event,
schema_name,
object.get("updatedInput"),
"/hookSpecificOutput/updatedInput",
)?;
validate_pre_tool_use_permission_fields(event, schema_name, object)?;
Ok(())
}
fn validate_optional_object_field(
event: &str,
schema_name: &str,
value: Option<&Value>,
path: &str,
) -> Result<(), HookOutputSchemaViolation> {
if value.is_some_and(|value| !value.is_object()) {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
path,
"field must be an object",
));
}
Ok(())
}
fn validate_pre_tool_use_permission_fields(
event: &str,
schema_name: &str,
object: &serde_json::Map<String, Value>,
) -> Result<(), HookOutputSchemaViolation> {
let Some(permission_decision) = object.get("permissionDecision") else {
validate_optional_string_field(
event,
schema_name,
object.get("permissionDecisionReason"),
"/hookSpecificOutput/permissionDecisionReason",
)?;
return Ok(());
};
let Some(permission_decision) = permission_decision.as_str() else {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
"/hookSpecificOutput/permissionDecision",
"permissionDecision must be a string",
));
};
if !["allow", "deny"].contains(&permission_decision) {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
"/hookSpecificOutput/permissionDecision",
"permissionDecision must be one of allow, deny",
));
}
if object.get("updatedInput").is_some() && permission_decision != "allow" {
return Err(HookOutputSchemaViolation::new(
event,
schema_name,
"/hookSpecificOutput/updatedInput",
"updatedInput is only supported with permissionDecision allow",
));
}
if permission_decision == "deny" {
require_non_empty_string_field(
event,
schema_name,
object.get("permissionDecisionReason"),
"/hookSpecificOutput/permissionDecisionReason",
"deny permissionDecision requires a non-empty permissionDecisionReason",
)?;
}
validate_optional_string_field(
event,
schema_name,
object.get("permissionDecisionReason"),
"/hookSpecificOutput/permissionDecisionReason",
)
}
fn create_user_prompt_submit_entry_context(probe: &Value) -> String {
let outcome = probe
.get("auto_entry_outcome")
.and_then(Value::as_str)
.unwrap_or("ignored");
let reason = probe
.pointer("/auto_entry/reason")
.or_else(|| probe.get("reason"))
.and_then(Value::as_str)
.unwrap_or("unknown");
let run_id = probe
.pointer("/auto_entry/run_id")
.and_then(Value::as_str)
.unwrap_or("none");
let next_step = probe
.pointer("/auto_entry/dispatch_next/next_step")
.and_then(Value::as_str)
.unwrap_or("none");
format!("auto_entry_outcome={outcome} reason={reason} run_id={run_id} next_step={next_step}")
}
fn hook_decision_context(payload: &Value) -> String {
let decisions = payload
.get("decisions")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter(|decision| {
decision.get("status").and_then(Value::as_str) == Some("decision")
})
.map(|decision| {
let tier = decision
.get("tier")
.and_then(Value::as_str)
.unwrap_or("unknown");
let action = decision
.get("action")
.and_then(Value::as_str)
.unwrap_or("unknown");
format!("{tier}:{action}")
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let required_actions = payload
.get("decisions")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|decision| decision.get("required_action").and_then(Value::as_str))
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if decisions.is_empty() && required_actions.is_empty() {
return String::new();
}
let mut parts = Vec::new();
if !decisions.is_empty() {
parts.push(format!(" decisions={}", decisions.join(",")));
}
if !required_actions.is_empty() {
parts.push(format!(" required_action={}", required_actions.join(",")));
}
parts.join("")
}
fn hook_entry_probe_surfaces_on_clear(event: &str, payload: &Value) -> bool {
event == "UserPromptSubmit"
&& matches!(
payload
.pointer("/entry_probe/auto_entry_outcome")
.and_then(Value::as_str),
Some("created")
)
}
pub(crate) fn create_lifecycle_hook_run_text(payload: &Value) -> String {
let entry_outcome = payload
.pointer("/entry_probe/auto_entry_outcome")
.and_then(Value::as_str)
.unwrap_or("none");
format!(
"Hook: event={} status={} active={} failed={} entry_auto_entry={}",
payload
.get("event")
.and_then(Value::as_str)
.unwrap_or("unknown"),
payload
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown"),
payload
.get("active_tiers")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0),
payload
.get("failed_tiers")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0),
entry_outcome,
)
}
fn guardrail_decision(status: &str) -> &'static str {
match status {
"decision" => "enforced",
"failed" => "failed",
_ => "skipped",
}
}
fn decision_tiers_with_status(decisions: &[Value], status: &str) -> Vec<String> {
decisions
.iter()
.filter(|decision| decision.get("status").and_then(Value::as_str) == Some(status))
.filter_map(|decision| decision.get("tier").and_then(Value::as_str))
.map(str::to_string)
.collect()
}
fn text_value<'a>(value: &'a Value, pointer: &str) -> Option<&'a str> {
value
.pointer(pointer)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}