use std::{
collections::BTreeSet,
fs,
io::{self, Read},
};
use serde::Deserialize;
use shepherd::run::RunKind;
use shepherd::{
GuardValue, Harness,
dispatch::{
AgentId, DispatchBinding, DispatchPlan, DispatchRequest, LaneId, RawIdentity, Role, RunId,
plan_lifecycle,
},
};
use crate::{
BindRootDispatchRequest, ContextInputs, DispatchService, DispatchStore, ExecutionContext,
cmd::{dispatch::read_project_id, guard::load_engine},
interface::{CliError, CliGlobals},
};
const MAX_HOOK_BYTES: usize = 1_048_576;
const DEFAULT_LEASE_MS: u64 = 86_400_000;
fn hook_label(harness: Harness) -> &'static str {
match harness {
Harness::ClaudeCode => "Claude",
Harness::Codex => "Codex",
Harness::Pi => "Pi",
Harness::PrimeAgent => "Prime",
_ => "unknown host",
}
}
fn capability_source(harness: Harness) -> &'static str {
match harness {
Harness::ClaudeCode => "claude-native-hook",
Harness::Codex => "codex-native-hook",
Harness::Pi => "pi-native-hook",
Harness::PrimeAgent => "prime-native-hook",
_ => "unknown-native-hook",
}
}
#[derive(Debug, Deserialize)]
struct NativeHookInput {
hook_event_name: String,
session_id: String,
#[serde(default)]
turn_id: Option<String>,
#[serde(default)]
agent_id: Option<String>,
#[serde(default)]
agent_type: Option<String>,
#[serde(default)]
tool_use_id: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
#[serde(alias = "claude_version")]
provider_version: Option<String>,
#[serde(default)]
tool_name: Option<String>,
#[serde(default)]
tool_input: Option<serde_json::Value>,
#[serde(default)]
shepherd_dispatch: Option<ClaudeDispatchBinding>,
#[serde(default)]
last_assistant_message: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ClaudeDispatchBinding {
#[serde(default)]
run: Option<String>,
#[serde(default)]
role: Option<String>,
#[serde(default)]
lane: Option<String>,
#[serde(default)]
parent_agent_id: Option<String>,
#[serde(default)]
write_scope: Option<Vec<String>>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
observed_capabilities: Option<BTreeSet<String>>,
#[serde(default)]
capability_source: Option<String>,
#[serde(default)]
harness_version: Option<String>,
#[serde(default)]
provider_version: Option<String>,
#[serde(default)]
lease_ms: Option<u64>,
#[serde(default)]
expected_revision: Option<u64>,
#[serde(default)]
result_artifact: Option<String>,
#[serde(default)]
source_agent_id: Option<String>,
#[serde(default)]
mode: Option<String>,
}
pub(super) fn run_native_hook(host: Harness, globals: CliGlobals) -> Result<(), CliError> {
let input = match read_input(host) {
Ok(input) => input,
Err(error) => return emit_parse_error(error, host),
};
let pre_tool_use = input.hook_event_name == "PreToolUse";
let hook_event_name = input.hook_event_name.clone();
match run_hook(input, host, globals) {
Ok(HookOutput::Silent) => Ok(()),
Ok(HookOutput::Context { event, detail }) => emit_json(&context(&event, &detail), host),
Ok(HookOutput::Deny { detail }) if hook_event_name == "SubagentStop" => {
emit_json(&block(&detail), host)
}
Ok(HookOutput::Deny { detail }) => emit_json(&deny(&hook_event_name, &detail), host),
Err(error) if pre_tool_use => emit_json(&deny("PreToolUse", &error.detail()), host),
Err(error) => emit_json(&context(&hook_event_name, &error.detail()), host),
}
}
enum HookOutput {
Silent,
Context { event: String, detail: String },
Deny { detail: String },
}
#[derive(Debug)]
enum HookFault {
CallerPolicy(String),
InvalidCustody(String),
UnsupportedCapability(String),
InternalCorruption(String),
}
impl HookFault {
fn lifecycle(error: impl std::fmt::Display) -> Self {
Self::InvalidCustody(error.to_string())
}
fn caller(error: impl std::fmt::Display) -> Self {
Self::CallerPolicy(error.to_string())
}
fn from_cli_caller(error: CliError) -> Self {
Self::CallerPolicy(
error
.message_text()
.unwrap_or("native hook request is invalid")
.into(),
)
}
fn from_cli_internal(error: CliError) -> Self {
Self::InternalCorruption(
error
.message_text()
.unwrap_or("native hook infrastructure failed")
.into(),
)
}
fn detail(&self) -> String {
match self {
Self::CallerPolicy(detail) => format!("caller policy violation: rejected: {detail}"),
Self::InvalidCustody(detail) => format!(
"lifecycle custody invalid: rejected: {detail}. Reload the plugin, re-bind the root, and respawn this worker; an existing session keeps the hook configuration it loaded at startup"
),
Self::UnsupportedCapability(detail) => {
format!("unsupported host capability: rejected: {detail}")
}
Self::InternalCorruption(detail) => format!(
"Shepherd internal corruption: rejected: {detail}. Run `shepherd doctor`; mutation remains denied until Native state is repaired"
),
}
}
}
impl From<CliError> for HookFault {
fn from(error: CliError) -> Self {
Self::from_cli_internal(error)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum BootstrapCommand {
ProjectInit,
RunInit {
run: RunId,
},
RunShow {
run: RunId,
},
BindRoot {
run: RunId,
mode: shepherd::dispatch::RootMode,
},
}
fn read_input(host: Harness) -> Result<NativeHookInput, CliError> {
let mut bytes = Vec::new();
io::stdin()
.take(u64::try_from(MAX_HOOK_BYTES + 1).expect("hook input limit fits in u64"))
.read_to_end(&mut bytes)
.map_err(|error| {
CliError::message(format!(
"cannot read {} hook input: {error}",
hook_label(host)
))
})?;
if bytes.len() > MAX_HOOK_BYTES {
return Err(CliError::message(format!(
"{} hook input exceeds 1048576-byte limit",
hook_label(host)
)));
}
serde_json::from_slice(&bytes).map_err(|_| {
CliError::message(format!(
"{} hook input must be one valid RFC 8259 JSON value",
hook_label(host)
))
})
}
fn emit_parse_error(error: CliError, host: Harness) -> Result<(), CliError> {
let fallback = format!("invalid {} hook input", hook_label(host));
emit_json(
&deny("PreToolUse", error.message_text().unwrap_or(&fallback)),
host,
)
}
fn bootstrap_command(input: &NativeHookInput) -> Option<BootstrapCommand> {
if input.hook_event_name != "PreToolUse" || input.tool_name.as_deref() != Some("Bash") {
return None;
}
let command = input.tool_input.as_ref()?.get("command")?.as_str()?;
if command == "shepherd init --confirm" {
return Some(BootstrapCommand::ProjectInit);
}
let arguments = command.split(' ').collect::<Vec<_>>();
if arguments.iter().any(|argument| argument.is_empty()) {
return None;
}
match arguments.as_slice() {
["shepherd", "run", "show", run, "--json"] => {
canonical_run(run).map(|run| BootstrapCommand::RunShow { run })
}
[
"shepherd",
"run",
"init",
run,
"--branch",
branch,
"--base",
base,
"--version",
version,
] => canonical_run_initializer(run, branch, base, version)
.map(|run| BootstrapCommand::RunInit { run }),
[
"shepherd",
"dispatch",
"bind-root",
"--run",
run,
"--mode",
mode @ ("planting" | "planning" | "execution"),
"--confirm",
] => canonical_run(run).map(|run| BootstrapCommand::BindRoot {
run,
mode: mode
.parse()
.unwrap_or(shepherd::dispatch::RootMode::Execution),
}),
_ => None,
}
}
fn canonical_run(value: &str) -> Option<RunId> {
if !crate::cmd::run::is_canonical(value) {
return None;
}
RunId::new(value).ok()
}
fn canonical_run_initializer(run: &str, branch: &str, base: &str, version: &str) -> Option<RunId> {
let run = canonical_run(run)?;
if !safe_git_ref(base) || !release_version(version) {
return None;
}
let version_ref = format!("v{version}");
if branch != version_ref || !safe_git_ref(branch) {
return None;
}
let kind = if version.contains("-dev.") {
RunKind::Sprint
} else {
RunKind::PatchArc
};
let derived = crate::cmd::run::derive_id(&version_ref, kind).ok()?;
(run.as_str() == derived).then_some(run)
}
fn release_version(value: &str) -> bool {
let (numbers, dev) = value.split_once("-dev.").unwrap_or((value, ""));
let mut parts = numbers.split('.');
let valid_numbers = [parts.next(), parts.next(), parts.next()]
.into_iter()
.all(|part| {
part.is_some_and(|part| {
!part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())
})
})
&& parts.next().is_none();
valid_numbers
&& (dev.is_empty() || (!dev.is_empty() && dev.bytes().all(|byte| byte.is_ascii_digit())))
}
fn safe_git_ref(value: &str) -> bool {
let bytes = value.as_bytes();
!bytes.is_empty()
&& bytes.len() <= 255
&& !value.starts_with(['/', '.'])
&& !value.ends_with(['/', '.'])
&& !value.ends_with(".lock")
&& !value.contains("..")
&& !value.contains("//")
&& !value.contains("@{")
&& bytes
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'-' | b'_' | b'.' | b'/'))
}
fn normalize_hook_tool_input(input: &mut NativeHookInput, host: Harness) -> Result<(), HookFault> {
if host != Harness::Codex
|| !matches!(input.hook_event_name.as_str(), "PreToolUse" | "PostToolUse")
|| input.tool_name.as_deref() != Some("apply_patch")
{
return Ok(());
}
let Some(fields) = input
.tool_input
.as_mut()
.and_then(serde_json::Value::as_object_mut)
else {
return Ok(());
};
let Some(command) = fields.get("command") else {
return Ok(());
};
if fields.contains_key("input") || fields.contains_key("patch") {
return Err(HookFault::caller(
"Codex apply_patch command must not be combined with input or patch",
));
}
if !command.is_string() {
return Err(HookFault::caller(
"Codex apply_patch command must be a string",
));
}
let command = fields.remove("command").expect("command was checked above");
fields.insert("input".into(), command);
Ok(())
}
fn run_hook(
mut input: NativeHookInput,
host: Harness,
globals: CliGlobals,
) -> Result<HookOutput, HookFault> {
normalize_hook_tool_input(&mut input, host)?;
if matches!(host, Harness::Codex | Harness::ClaudeCode)
&& matches!(
input.hook_event_name.as_str(),
"PreToolUse" | "SubagentStart" | "SubagentResume" | "SubagentStop" | "Stop"
)
&& std::env::var_os("SHEPHERD_NATIVE_BROKER_ENDPOINT").is_some()
{
return run_broker_hook(&input, host, globals);
}
if input.hook_event_name == "Stop" {
return Ok(HookOutput::Silent);
}
let bootstrap = bootstrap_command(&input);
if bootstrap == Some(BootstrapCommand::ProjectInit) {
return Ok(HookOutput::Context {
event: "PreToolUse".into(),
detail: "authorized exact native project initialization".into(),
});
}
let identity = RawIdentity::new(
host,
&input.hook_event_name,
input.session_id.clone(),
input.agent_id.as_deref(),
input.agent_type.as_deref(),
input.tool_use_id.as_deref(),
input.model.as_deref(),
input.provider_version.as_deref(),
)
.normalize()
.map_err(HookFault::caller)?;
if matches!(
input.hook_event_name.as_str(),
"SubagentStart" | "SubagentResume" | "SubagentStop"
) {
return Ok(HookOutput::Context {
event: input.hook_event_name.clone(),
detail: format!(
"unsupported host capability: rejected: raw {} is correlation only and cannot authorize child lifecycle; use the native broker launch exchange",
input.hook_event_name
),
});
}
if input.hook_event_name == "SessionStart"
&& input
.shepherd_dispatch
.as_ref()
.and_then(|binding| binding.run.as_deref())
.is_none()
{
return Ok(HookOutput::Context {
event: "SessionStart".into(),
detail: format!(
"root session remains unbound until an explicit run bootstrap; lifecycle_contract=shepherd-hook/1 cli={}. Hook configuration is fixed at session start, so reload the plugin and respawn existing workers after an upgrade; a new-session probe does not validate older sessions",
env!("CARGO_PKG_VERSION")
),
});
}
let binding =
binding_for(&input, identity.event.as_str(), host).map_err(HookFault::from_cli_caller)?;
let plan = plan_lifecycle(&identity, binding.as_ref()).map_err(HookFault::caller)?;
let DispatchPlan::Request(request) = plan else {
return match plan {
DispatchPlan::Ignored => Ok(HookOutput::Silent),
DispatchPlan::Blocked(error) => Err(HookFault::lifecycle(error)),
DispatchPlan::Request(_) => unreachable!("request was matched above"),
};
};
let context = execution_context(globals).map_err(HookFault::from_cli_internal)?;
let project_id =
read_project_id(&context.project_id_path).map_err(HookFault::from_cli_internal)?;
let service = DispatchService::with_context(
DispatchStore::new(&context.runs_root),
project_id,
&context.workspace_root,
&context.registry_path,
);
let now = context.now_unix_millis();
match bootstrap {
Some(BootstrapCommand::RunInit { run }) => {
let path = context.runs_root.join(run.as_str()).join("run.json");
match fs::symlink_metadata(&path) {
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Ok(_) => {
return Ok(HookOutput::Deny {
detail: format!("run `{run}` already exists; init bootstrap refused"),
});
}
Err(error) => {
return Ok(HookOutput::Deny {
detail: format!("cannot inspect selected run `{run}`: {error}"),
});
}
}
return Ok(HookOutput::Context {
event: "PreToolUse".into(),
detail: format!("authorized exact native initialization for run {run}"),
});
}
Some(BootstrapCommand::RunShow { run }) => {
service
.selected_run_status(run.as_str())
.map_err(service_fault)?;
return Ok(HookOutput::Context {
event: "PreToolUse".into(),
detail: format!("authorized exact read-only inspection for run {run}"),
});
}
Some(BootstrapCommand::BindRoot { run, mode }) => {
let response = service
.bind_root(
BindRootDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some(run.to_string()),
harness: host,
session_id: input.session_id.clone(),
role_carrier: Role::Shepherd.carrier(),
mode,
lease_ms: DEFAULT_LEASE_MS,
},
now,
)
.map_err(service_fault)?;
return Ok(HookOutput::Context {
event: "PreToolUse".into(),
detail: format!("bound trusted root session to run {}", response.run),
});
}
Some(BootstrapCommand::ProjectInit) => unreachable!("handled before discovery"),
None => {}
}
match request {
DispatchRequest::BindRoot(request) => {
let request: BindRootDispatchRequest = decode_request(&request)?;
let response = service.bind_root(request, now).map_err(service_fault)?;
Ok(HookOutput::Context {
event: input.hook_event_name,
detail: format!("bound root session to run {}", response.run),
})
}
DispatchRequest::Start(_) => Err(HookFault::UnsupportedCapability(
"child lifecycle requires the native broker launch exchange".into(),
)),
DispatchRequest::Stop(request) => {
let request = decode_request(&request)?;
let response = service.stop(request, now).map_err(service_fault)?;
Ok(HookOutput::Context {
event: input.hook_event_name,
detail: format!("stopped native dispatch {}", response.agent_id),
})
}
DispatchRequest::Resume(_) => Err(HookFault::UnsupportedCapability(
"child lifecycle requires the native broker launch exchange".into(),
)),
DispatchRequest::Resolve(request) => {
let request = decode_request(&request)?;
match service.resolve(request, now) {
Ok(_) if input.hook_event_name == "PostToolUse" => Ok(HookOutput::Silent),
Ok(response) => evaluate_pre_tool_use(&input, &context, &response),
Err(error) if input.hook_event_name == "PostToolUse" => Ok(HookOutput::Context {
event: input.hook_event_name.clone(),
detail: format!("dispatch state unavailable after the tool ran: {error}"),
}),
Err(error) => Ok(unresolved_pre_tool_use(&input, &context, &error)),
}
}
}
}
fn run_broker_hook(
input: &NativeHookInput,
host: Harness,
globals: CliGlobals,
) -> Result<HookOutput, HookFault> {
use super::native_broker::{
ChildEvent, NativeChildEventRequest, handle_child_event, handle_child_event_with_result,
};
let env = |name: &str| {
std::env::var(name)
.map_err(|_| HookFault::lifecycle(format!("missing Native broker correlation: {name}")))
};
let managed_thread = std::env::var("SHEPHERD_NATIVE_BROKER_CODEX_THREAD").as_deref() == Ok("1");
let session = if managed_thread {
input.session_id.clone()
} else {
env("SHEPHERD_NATIVE_BROKER_SESSION_ID")?
};
let agent = if managed_thread {
input.session_id.clone()
} else {
env("SHEPHERD_NATIVE_BROKER_AGENT_ID")?
};
let agent_type = env("SHEPHERD_NATIVE_BROKER_AGENT_TYPE")?;
validate_broker_correlation(input, host, &session, &agent, &agent_type)?;
let run = RunId::new(env("SHEPHERD_NATIVE_BROKER_RUN")?).map_err(HookFault::caller)?;
let agent_id = AgentId::new(agent.clone()).map_err(HookFault::caller)?;
let context = execution_context(globals).map_err(HookFault::from_cli_internal)?;
let store = DispatchStore::new(&context.runs_root);
let event = |event| NativeChildEventRequest {
schema: "shepherd.native-child-event/1".into(),
harness: host,
event,
session_id: input.session_id.clone(),
agent_id: agent.clone(),
agent_type: agent_type.clone(),
turn_id: input.turn_id.clone(),
last_assistant_message: None,
};
match input.hook_event_name.as_str() {
"SubagentStart" => {
match store.load_for_run(&run, &agent_id) {
Ok(_) => {}
Err(crate::DispatchStoreError::UnknownRecord { .. }) => {
handle_child_event(event(ChildEvent::Start))
.map_err(HookFault::from_cli_caller)?;
}
Err(error) => return Err(HookFault::lifecycle(error)),
}
return Ok(HookOutput::Silent);
}
"SubagentResume" => {
handle_child_event(event(ChildEvent::Resume)).map_err(HookFault::from_cli_caller)?;
return Ok(HookOutput::Silent);
}
"Stop" | "SubagentStop" => {
handle_child_event_with_result(
event(ChildEvent::Stop),
input.last_assistant_message.clone(),
)
.map_err(HookFault::from_cli_caller)?;
return Ok(HookOutput::Silent);
}
_ => {}
}
match store.load_for_run(&run, &agent_id) {
Ok(_) => {}
Err(crate::DispatchStoreError::UnknownRecord { .. }) => {
handle_child_event(event(ChildEvent::Start)).map_err(HookFault::from_cli_caller)?;
}
Err(error) => return Err(HookFault::lifecycle(error)),
}
let project_id =
read_project_id(&context.project_id_path).map_err(HookFault::from_cli_internal)?;
let service = DispatchService::with_context(
store,
project_id,
&context.workspace_root,
&context.registry_path,
);
let resolution = service
.resolve(
crate::ResolveDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some(run.to_string()),
harness: host,
agent_id: Some(agent),
agent_type: Some(agent_type),
role_carrier: None,
lane: None,
session_id: input.session_id.clone(),
tool_call_id: input.tool_use_id.clone(),
tool_name: input.tool_name.clone(),
tool_input: input.tool_input.clone(),
},
context.now_unix_millis(),
)
.map_err(service_fault)?;
evaluate_pre_tool_use(input, &context, &resolution)
}
fn validate_broker_correlation(
input: &NativeHookInput,
host: Harness,
session: &str,
agent: &str,
agent_type: &str,
) -> Result<(), HookFault> {
if input.session_id != session
|| input
.agent_id
.as_deref()
.is_some_and(|value| value != agent)
|| input
.agent_type
.as_deref()
.is_some_and(|value| value != agent_type)
|| (host == Harness::Codex && input.turn_id.as_deref().is_none_or(str::is_empty))
|| input.shepherd_dispatch.is_some()
{
return Err(HookFault::lifecycle(
"hook does not match Native broker identity and real turn",
));
}
Ok(())
}
fn execution_context(globals: CliGlobals) -> Result<ExecutionContext, CliError> {
let cwd = std::env::current_dir()
.map_err(|error| CliError::message(format!("cannot resolve current directory: {error}")))?;
let mut inputs = ContextInputs::from_environment(cwd)
.map_err(|error| CliError::message(error.to_string()))?;
inputs.explicit_config = globals.config;
inputs.verbosity = globals.verbosity;
ExecutionContext::discover(inputs).map_err(|error| CliError::message(error.to_string()))
}
fn binding_for(
input: &NativeHookInput,
event: &str,
host: Harness,
) -> Result<Option<DispatchBinding>, CliError> {
let Some(raw) = input.shepherd_dispatch.as_ref() else {
if event == "SubagentStop" {
let Some(agent_type) = input.agent_type.as_deref() else {
return Ok(None);
};
let role = parse_role(agent_type).map_err(|error| {
CliError::message(format!("cannot resolve dispatched role: {error}"))
})?;
let observed = role
.dispatch_capability_contract()
.map_err(|error| CliError::message(error.to_string()))?
.required;
let mut binding = DispatchBinding::new(
None,
Some(role),
None,
None,
Vec::new(),
input.model.clone(),
observed,
capability_source(host),
"unknown",
input.provider_version.as_deref(),
DEFAULT_LEASE_MS,
)
.map_err(|error| CliError::message(error.to_string()))?;
binding.mode = shepherd::dispatch::RootMode::Execution;
return Ok(Some(binding));
}
if matches!(event, "PreToolUse" | "PostToolUse") {
let mut binding = DispatchBinding::root(
Role::Shepherd,
shepherd::dispatch::RootMode::Execution,
DEFAULT_LEASE_MS,
)
.map_err(|error| CliError::message(error.to_string()))?;
binding.tool_name = input.tool_name.clone();
binding.tool_input = input.tool_input.clone();
return Ok(Some(binding));
}
return Ok(None);
};
let role = raw
.role
.as_deref()
.map(parse_role)
.transpose()
.map_err(|error| CliError::message(error.to_string()))?;
let write_scope = raw.write_scope.clone().unwrap_or_else(|| vec!["**".into()]);
let mut binding = DispatchBinding::new(
raw.run
.as_deref()
.map(RunId::new)
.transpose()
.map_err(|error| CliError::message(error.to_string()))?,
role,
raw.lane
.as_deref()
.map(LaneId::new)
.transpose()
.map_err(|error| CliError::message(error.to_string()))?,
raw.parent_agent_id
.as_deref()
.map(AgentId::new)
.transpose()
.map_err(|error| CliError::message(error.to_string()))?,
write_scope,
raw.model.clone(),
raw.observed_capabilities.clone().unwrap_or_default(),
raw.capability_source
.as_deref()
.unwrap_or(capability_source(host)),
raw.harness_version.as_deref().unwrap_or("unknown"),
raw.provider_version.as_deref(),
raw.lease_ms.unwrap_or(DEFAULT_LEASE_MS),
)
.map_err(|error| CliError::message(error.to_string()))?;
binding.expected_revision = raw.expected_revision.unwrap_or(1);
binding.result_artifact = raw.result_artifact.clone();
binding.source_agent_id = raw
.source_agent_id
.as_deref()
.map(AgentId::new)
.transpose()
.map_err(|error| CliError::message(error.to_string()))?;
binding.mode = raw
.mode
.as_deref()
.and_then(|mode| mode.parse().ok())
.unwrap_or(shepherd::dispatch::RootMode::Execution);
if matches!(event, "PreToolUse" | "PostToolUse") {
binding.tool_name = input.tool_name.clone();
binding.tool_input = input.tool_input.clone();
}
Ok(Some(binding))
}
fn parse_role(value: &str) -> shepherd::dispatch::DispatchResult<Role> {
if value.starts_with("shepherd:") {
Role::from_carrier(value)
} else {
Role::from_name(value)
}
}
fn decode_request<T: serde::de::DeserializeOwned>(
value: &impl serde::Serialize,
) -> Result<T, HookFault> {
serde_json::to_value(value)
.and_then(serde_json::from_value)
.map_err(|error| {
HookFault::caller(format!("cannot decode planned native dispatch: {error}"))
})
}
fn service_fault(error: crate::DispatchServiceError) -> HookFault {
match crate::dispatch_service::classify_dispatch_error(&error) {
crate::dispatch_service::DispatchFaultClass::CallerPolicyViolation => {
HookFault::CallerPolicy(error.to_string())
}
crate::dispatch_service::DispatchFaultClass::InvalidLifecycleCustody => {
HookFault::InvalidCustody(error.to_string())
}
crate::dispatch_service::DispatchFaultClass::UnsupportedHostCapability => {
HookFault::UnsupportedCapability(error.to_string())
}
crate::dispatch_service::DispatchFaultClass::InternalCorruption => {
HookFault::InternalCorruption(error.to_string())
}
}
}
fn cli_error_detail(error: &CliError) -> &str {
error
.message_text()
.unwrap_or("native hook rejected the request")
}
fn evaluate_pre_tool_use(
input: &NativeHookInput,
context: &ExecutionContext,
resolution: &crate::DispatchResolution,
) -> Result<HookOutput, HookFault> {
let engine = match load_engine(None) {
Ok(engine) => engine.with_open_flock(context.config.guard.open_flock),
Err(error) => return Ok(guard_unavailable(input, cli_error_detail(&error))),
};
let tool_input = match (&input.tool_name, &input.tool_input) {
(Some(tool_name), Some(serde_json::Value::String(patch))) if tool_name == "apply_patch" => {
serde_json::json!({"input": patch})
}
(_, Some(value)) => value.clone(),
(_, None) => serde_json::Value::Object(Default::default()),
};
let request = serde_json::json!({
"role": resolution.role.as_str(),
"tool_name": input.tool_name.as_deref().unwrap_or_default(),
"tool_input": tool_input,
"dispatch": resolution,
});
let verdict = match engine.evaluate(&GuardValue::from(request)) {
Ok(verdict) => verdict,
Err(error) => return Ok(guard_unavailable(input, &error.to_string())),
};
if verdict.decision.as_str() == "allow" {
Ok(HookOutput::Silent)
} else {
Ok(HookOutput::Deny {
detail: format!(
"caller policy violation: {}",
verdict
.reason
.unwrap_or_else(|| "Shepherd denied this tool request".into())
),
})
}
}
fn unresolved_pre_tool_use(
input: &NativeHookInput,
context: &ExecutionContext,
error: &crate::DispatchServiceError,
) -> HookOutput {
if is_unbound_session(error)
&& !context
.config
.guard
.dispatch_custody
.enforced( false)
{
return HookOutput::Context {
event: input.hook_event_name.clone(),
detail: unbound_session_reason(error),
};
}
HookOutput::Deny {
detail: unbound_session_reason(error),
}
}
fn is_unbound_session(error: &crate::DispatchServiceError) -> bool {
matches!(
error,
crate::DispatchServiceError::Store(crate::DispatchStoreError::Io { source, .. })
if source.kind() == io::ErrorKind::NotFound
) || matches!(
error,
crate::DispatchServiceError::Identity(
shepherd::dispatch::IdentityError::MissingRootBinding
) | crate::DispatchServiceError::Store(crate::DispatchStoreError::Identity(
shepherd::dispatch::IdentityError::MissingRootBinding
))
)
}
fn unbound_session_reason(error: &crate::DispatchServiceError) -> String {
let is_unbound_session = matches!(
error,
crate::DispatchServiceError::Store(crate::DispatchStoreError::Io { source, .. })
if source.kind() == io::ErrorKind::NotFound
) || matches!(
error,
crate::DispatchServiceError::Identity(
shepherd::dispatch::IdentityError::MissingRootBinding
) | crate::DispatchServiceError::Store(crate::DispatchStoreError::Identity(
shepherd::dispatch::IdentityError::MissingRootBinding
))
);
if is_unbound_session {
"this session is not bound to a shepherd run. Run /shepherd:spawn to \
select, bootstrap, and bind one before mutating the workspace; /shepherd:start \
only resumes a run that is already planned or executing."
.into()
} else {
let class = crate::dispatch_service::classify_dispatch_error(error);
let label = match class {
crate::dispatch_service::DispatchFaultClass::CallerPolicyViolation => {
"caller policy violation"
}
crate::dispatch_service::DispatchFaultClass::InvalidLifecycleCustody => {
"lifecycle custody invalid"
}
crate::dispatch_service::DispatchFaultClass::UnsupportedHostCapability => {
"unsupported host capability"
}
crate::dispatch_service::DispatchFaultClass::InternalCorruption => {
"Shepherd internal corruption"
}
};
format!("{label}: rejected: {error}")
}
}
fn guard_unavailable(_input: &NativeHookInput, detail: &str) -> HookOutput {
HookOutput::Deny {
detail: format!(
"Shepherd internal corruption: guard engine unavailable: {detail}. Run `shepherd doctor`; mutation remains denied until Native state is repaired"
),
}
}
fn context(event: &str, detail: &str) -> serde_json::Value {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": event,
"additionalContext": format!("[shepherd] {detail}"),
}
})
}
fn deny(event: &str, detail: &str) -> serde_json::Value {
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": event,
"permissionDecision": "deny",
"permissionDecisionReason": format!("[shepherd] {detail}"),
}
})
}
fn block(detail: &str) -> serde_json::Value {
serde_json::json!({
"decision": "block",
"reason": format!("[shepherd] {detail}"),
})
}
fn emit_json(value: &serde_json::Value, host: Harness) -> Result<(), CliError> {
let value = serde_json::to_string(value).map_err(|error| {
CliError::message(format!(
"cannot encode {} hook output: {error}",
hook_label(host)
))
})?;
println!("{value}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::{HookFault, service_fault};
use crate::DispatchStoreError;
use shepherd::{
Harness,
dispatch::{AgentId, RunId},
};
fn codex_patch_input(tool_input: serde_json::Value) -> super::NativeHookInput {
serde_json::from_value(serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "codex-envelope-test",
"tool_name": "apply_patch",
"tool_input": tool_input,
}))
.unwrap()
}
const CODEX_PATCH: &str =
"*** Begin Patch\n*** Add File: report.md\n+evidence\n*** End Patch\n";
#[test]
fn codex_broker_hook_requires_actual_thread_and_turn_without_caller_authority() {
let mut input = codex_patch_input(serde_json::json!({"command": CODEX_PATCH}));
let verify = |input: &super::NativeHookInput| {
super::validate_broker_correlation(
input,
Harness::Codex,
"codex-envelope-test",
"child",
"worker",
)
};
assert!(
verify(&input).is_err(),
"a turnless lifecycle event cannot activate"
);
input.turn_id = Some("host-turn".into());
assert!(verify(&input).is_ok());
input.session_id = "parent-session".into();
assert!(
verify(&input).is_err(),
"parent session is not child identity"
);
input.session_id = "codex-envelope-test".into();
input.agent_id = Some("sibling".into());
assert!(verify(&input).is_err());
input.agent_id = Some("child".into());
input.agent_type = Some("explorer".into());
assert!(verify(&input).is_err());
input.agent_type = Some("worker".into());
assert!(verify(&input).is_ok());
input.shepherd_dispatch = Some(super::ClaudeDispatchBinding::default());
assert!(
verify(&input).is_err(),
"hook JSON cannot supply launch authority"
);
}
#[test]
fn stop_payload_preserves_last_assistant_message_for_native_result_transport() {
let input: super::NativeHookInput = serde_json::from_value(serde_json::json!({
"hook_event_name": "Stop",
"session_id": "codex-child",
"turn_id": "turn-1",
"last_assistant_message": "{\"schema\":\"shepherd.review-result/1\"}"
}))
.unwrap();
assert_eq!(
input.last_assistant_message.as_deref(),
Some("{\"schema\":\"shepherd.review-result/1\"}")
);
}
#[test]
fn codex_command_envelope_reaches_native_write_path_derivation() {
let workspace = tempfile::tempdir().unwrap();
let mut input = codex_patch_input(serde_json::json!({"command": CODEX_PATCH}));
super::normalize_hook_tool_input(&mut input, shepherd::Harness::Codex).unwrap();
let binding = super::binding_for(&input, "PreToolUse", shepherd::Harness::Codex)
.unwrap()
.unwrap();
assert_eq!(
binding.tool_input,
Some(serde_json::json!({"input": CODEX_PATCH}))
);
let paths = crate::dispatch_scope::derive_write_paths(
workspace.path(),
binding.tool_name.as_deref(),
binding.tool_input.as_ref(),
)
.unwrap();
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].path, "report.md");
assert!(paths[0].inside_workspace);
}
#[test]
fn claude_broker_correlation_allows_missing_turn_but_keeps_identity_bound() {
let input: super::NativeHookInput = serde_json::from_value(serde_json::json!({
"hook_event_name": "SubagentStart",
"session_id": "claude-child-session",
}))
.unwrap();
assert!(
super::validate_broker_correlation(
&input,
Harness::ClaudeCode,
"claude-child-session",
"child-agent",
"engineer",
)
.is_ok()
);
assert!(
super::validate_broker_correlation(
&input,
Harness::ClaudeCode,
"other-session",
"child-agent",
"engineer",
)
.is_err()
);
}
#[test]
fn codex_normalization_preserves_patch_bytes_and_is_idempotent() {
let patch =
"*** Begin Patch\r\n*** Add File: report.md\r\n+naive \u{03bb}\r\n*** End Patch\r\n";
let mut input = codex_patch_input(serde_json::json!({"command": patch}));
super::normalize_hook_tool_input(&mut input, shepherd::Harness::Codex).unwrap();
assert_eq!(input.tool_input, Some(serde_json::json!({"input": patch})));
let once = input.tool_input.clone();
super::normalize_hook_tool_input(&mut input, shepherd::Harness::Codex).unwrap();
assert_eq!(input.tool_input, once);
}
#[test]
fn codex_legacy_patch_inputs_remain_unchanged() {
for payload in [
serde_json::json!(CODEX_PATCH),
serde_json::json!({"input": CODEX_PATCH}),
serde_json::json!({"patch": CODEX_PATCH}),
] {
let mut input = codex_patch_input(payload.clone());
super::normalize_hook_tool_input(&mut input, shepherd::Harness::Codex).unwrap();
assert_eq!(input.tool_input, Some(payload));
}
}
#[test]
fn codex_competing_patch_aliases_fail_without_mutating_input() {
for alias in ["input", "patch"] {
let mut payload = serde_json::json!({"command": CODEX_PATCH});
payload[alias] = serde_json::json!(CODEX_PATCH);
let mut input = codex_patch_input(payload.clone());
let error =
super::normalize_hook_tool_input(&mut input, shepherd::Harness::Codex).unwrap_err();
assert!(matches!(error, HookFault::CallerPolicy(_)));
assert_eq!(input.tool_input, Some(payload));
}
}
#[test]
fn codex_non_string_commands_fail_without_mutating_input() {
for command in [
serde_json::Value::Null,
serde_json::json!(false),
serde_json::json!(42),
serde_json::json!([]),
serde_json::json!({}),
] {
let payload = serde_json::json!({"command": command});
let mut input = codex_patch_input(payload.clone());
let error =
super::normalize_hook_tool_input(&mut input, shepherd::Harness::Codex).unwrap_err();
assert!(matches!(error, HookFault::CallerPolicy(_)));
assert_eq!(input.tool_input, Some(payload));
}
}
#[test]
fn codex_normalization_does_not_change_other_harnesses_or_tools() {
for (host, tool) in [
(shepherd::Harness::ClaudeCode, "apply_patch"),
(shepherd::Harness::Pi, "apply_patch"),
(shepherd::Harness::PrimeAgent, "apply_patch"),
(shepherd::Harness::Codex, "Bash"),
(shepherd::Harness::Codex, "Edit"),
] {
let payload = serde_json::json!({"command": CODEX_PATCH});
let mut input = codex_patch_input(payload.clone());
input.tool_name = Some(tool.into());
super::normalize_hook_tool_input(&mut input, host).unwrap();
assert_eq!(input.tool_input, Some(payload));
}
}
#[test]
fn codex_unknown_fields_remain_visible_to_native_validation() {
let workspace = tempfile::tempdir().unwrap();
let mut input = codex_patch_input(serde_json::json!({
"command": CODEX_PATCH,
"unexpected": true,
}));
super::normalize_hook_tool_input(&mut input, shepherd::Harness::Codex).unwrap();
assert_eq!(
input.tool_input,
Some(serde_json::json!({"input": CODEX_PATCH, "unexpected": true}))
);
let error = crate::dispatch_scope::derive_write_paths(
workspace.path(),
input.tool_name.as_deref(),
input.tool_input.as_ref(),
)
.unwrap_err();
assert!(error.to_string().contains("unknown field `unexpected`"));
}
#[test]
fn codex_unsafe_patch_targets_stay_rejected() {
let workspace = tempfile::tempdir().unwrap();
for patch in [
"",
"not a patch",
"*** Add File: report.md\n+\0\n",
"*** Add File: ../escape.md\n+no\n",
] {
let mut input = codex_patch_input(serde_json::json!({"command": patch}));
super::normalize_hook_tool_input(&mut input, shepherd::Harness::Codex).unwrap();
assert!(
crate::dispatch_scope::derive_write_paths(
workspace.path(),
input.tool_name.as_deref(),
input.tool_input.as_ref(),
)
.is_err(),
"unsafe patch accepted: {patch:?}"
);
}
}
#[test]
fn codex_update_delete_and_move_targets_reach_native_validation() {
let workspace = tempfile::tempdir().unwrap();
std::fs::write(workspace.path().join("old.md"), "old\n").unwrap();
for (patch, expected) in [
(
"*** Begin Patch\n*** Update File: old.md\n@@\n-old\n+new\n*** End Patch\n",
vec!["old.md"],
),
(
"*** Begin Patch\n*** Delete File: old.md\n*** End Patch\n",
vec!["old.md"],
),
(
"*** Begin Patch\n*** Update File: old.md\n*** Move to: moved.md\n@@\n-old\n+new\n*** End Patch\n",
vec!["moved.md", "old.md"],
),
] {
let mut input = codex_patch_input(serde_json::json!({"command": patch}));
super::normalize_hook_tool_input(&mut input, shepherd::Harness::Codex).unwrap();
let paths = crate::dispatch_scope::derive_write_paths(
workspace.path(),
input.tool_name.as_deref(),
input.tool_input.as_ref(),
)
.unwrap();
assert_eq!(
paths
.iter()
.map(|target| target.path.as_str())
.collect::<Vec<_>>(),
expected
);
assert!(paths.iter().all(|target| target.inside_workspace));
}
}
#[test]
fn codex_absolute_outside_target_is_not_reclassified_as_workspace_owned() {
let workspace = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("outside.md");
let patch = format!(
"*** Begin Patch\n*** Add File: {}\n+no\n*** End Patch\n",
target.display()
);
let mut input = codex_patch_input(serde_json::json!({"command": patch}));
super::normalize_hook_tool_input(&mut input, shepherd::Harness::Codex).unwrap();
let paths = crate::dispatch_scope::derive_write_paths(
workspace.path(),
input.tool_name.as_deref(),
input.tool_input.as_ref(),
)
.unwrap();
assert_eq!(paths.len(), 1);
assert!(!paths[0].inside_workspace);
}
#[test]
fn hook_faults_keep_four_operator_actionable_classes() {
let cases = [
(
HookFault::caller("tool payload is not authorized"),
"caller policy violation",
),
(
HookFault::lifecycle("session record is ambiguous"),
"lifecycle custody invalid",
),
(
HookFault::UnsupportedCapability("host has no broker correlation".into()),
"unsupported host capability",
),
(
HookFault::InternalCorruption("dispatch index is corrupt".into()),
"Shepherd internal corruption",
),
];
for (fault, class) in cases {
let detail = fault.detail();
assert!(detail.contains(class), "{detail}");
assert!(detail.contains("rejected"), "{detail}");
}
}
#[test]
fn missing_agent_id_record_is_lifecycle_custody_not_internal_corruption() {
let run = RunId::new("v657").unwrap();
let agent = AgentId::new("missing-agent").unwrap();
let fault = service_fault(crate::DispatchServiceError::Store(
DispatchStoreError::UnknownRecord {
run,
agent_id: agent,
reason: "record is absent".into(),
},
));
assert!(fault.detail().starts_with("lifecycle custody invalid"));
}
}