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)]
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>,
}
#[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",
&format!("dispatch state unavailable: {}", cli_error_detail(&error)),
),
host,
),
Err(error) => emit_json(
&context(
&hook_event_name,
&format!(
"native lifecycle hook rejected: {}",
cli_error_detail(&error)
),
),
host,
),
}
}
enum HookOutput {
Silent,
Context { event: String, detail: String },
Deny { detail: String },
}
#[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 run_hook(
input: NativeHookInput,
host: Harness,
globals: CliGlobals,
) -> Result<HookOutput, CliError> {
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(|error| CliError::message(error.to_string()))?;
if matches!(
input.hook_event_name.as_str(),
"SubagentStart" | "SubagentResume" | "SubagentStop"
) {
return Err(CliError::message(
"child lifecycle requires the native broker launch exchange",
));
}
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: "root session remains unbound until an explicit run bootstrap".into(),
});
}
let binding = binding_for(&input, identity.event.as_str(), host)?;
let plan = plan_lifecycle(&identity, binding.as_ref())
.map_err(|error| CliError::message(error.to_string()))?;
let DispatchPlan::Request(request) = plan else {
return match plan {
DispatchPlan::Ignored => Ok(HookOutput::Silent),
DispatchPlan::Blocked(error) => Err(CliError::message(error.to_string())),
DispatchPlan::Request(_) => unreachable!("request was matched above"),
};
};
let context = execution_context(globals)?;
let project_id = read_project_id(&context.project_id_path)?;
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_error)?;
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_error)?;
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_error)?;
Ok(HookOutput::Context {
event: input.hook_event_name,
detail: format!("bound root session to run {}", response.run),
})
}
DispatchRequest::Start(_) => Err(CliError::message(
"child lifecycle requires the native broker launch exchange",
)),
DispatchRequest::Stop(request) => {
let request = decode_request(&request)?;
let response = service.stop(request, now).map_err(service_error)?;
Ok(HookOutput::Context {
event: input.hook_event_name,
detail: format!("stopped native dispatch {}", response.agent_id),
})
}
DispatchRequest::Resume(_) => Err(CliError::message(
"child lifecycle requires the native broker launch exchange",
)),
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 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, CliError> {
serde_json::to_value(value)
.and_then(serde_json::from_value)
.map_err(|error| {
CliError::message(format!("cannot decode planned native dispatch: {error}"))
})
}
fn service_error(error: crate::DispatchServiceError) -> CliError {
CliError::message(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, CliError> {
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: 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 {
error.to_string()
}
}
fn guard_unavailable(_input: &NativeHookInput, detail: &str) -> HookOutput {
HookOutput::Deny {
detail: format!("guard engine unavailable: {detail}"),
}
}
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(())
}