use std::{
fs,
io::Write,
path::{Path, PathBuf},
process::{Command, Output, Stdio},
time::{SystemTime, UNIX_EPOCH},
};
fn binary() -> &'static str {
env!("CARGO_BIN_EXE_shepherd")
}
fn fixture_dir(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let root = std::env::temp_dir().join(format!(
"shepherd-claude-hook-{label}-{}-{nonce:x}",
std::process::id()
));
fs::create_dir_all(&root).expect("create fixture directory");
fs::canonicalize(root).expect("canonical fixture directory")
}
fn repository(label: &str) -> PathBuf {
let root = fixture_dir(label);
let status = Command::new("git")
.args(["init", "--quiet"])
.current_dir(&root)
.status()
.expect("initialize fixture repository");
assert!(status.success());
fs::create_dir_all(root.join(".shepherd/runs/v645/dispatch")).expect("create run namespace");
fs::write(
root.join(".shepherd/project.json"),
br#"{"id":"018f47ce-72d7-7f64-9eb1-2f651d521c2a","scaffolded_at":1000}"#,
)
.expect("write project identity");
fs::write(
root.join(".shepherd/runs/v645/run.json"),
br#"{"run":"v645","status":"executing"}"#,
)
.expect("write active run");
let registry = shepherd_cli::shepherd::registry::Registry::open_migrated(
root.join(".shepherd/shepherd.db"),
)
.expect("authoritative registry");
registry
.execute(
"INSERT INTO projects (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)",
(
"018f47ce-72d7-7f64-9eb1-2f651d521c2a",
"Claude hook fixture",
1_i64,
),
)
.expect("register project identity");
root
}
fn enforce_custody_always(root: &Path) {
fs::write(
root.join(".shepherd/shepherd.toml"),
b"[guard]\ndispatch_custody = \"always\"\n",
)
.expect("write guard configuration");
}
fn decision(output: &serde_json::Value) -> Option<&str> {
output["hookSpecificOutput"]["permissionDecision"].as_str()
}
fn advice(output: &serde_json::Value) -> &str {
output["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.or_else(|| output["hookSpecificOutput"]["additionalContext"].as_str())
.expect("a hook response says what it decided and why")
}
fn hook(root: &Path, input: serde_json::Value) -> Output {
let mut child = Command::new(binary())
.args(["hook", "--harness", "claude"])
.current_dir(root)
.env("SHEPHERD_HOME", root.join("isolated-home"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn shepherd claude hook");
child
.stdin
.take()
.expect("hook stdin")
.write_all(&serde_json::to_vec(&input).expect("encode hook input"))
.expect("write hook input");
child.wait_with_output().expect("wait for hook")
}
#[test]
fn exact_bind_root_uses_the_trusted_claude_session_and_selected_planted_run() {
let root = repository("explicit-root-binding");
fs::create_dir_all(root.join(".shepherd/runs/v657/dispatch")).expect("v657 dispatch namespace");
fs::write(
root.join(".shepherd/runs/v657/run.json"),
br#"{"run":"v657","status":"planted"}"#,
)
.expect("v657 planted state");
fs::create_dir_all(root.join(".shepherd/runs/v646")).expect("corrupt sibling namespace");
fs::write(root.join(".shepherd/runs/v646/run.json"), b"{").expect("corrupt sibling document");
let bound = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "trusted-claude-session",
"tool_use_id": "bind-v657",
"tool_name": "Bash",
"tool_input": {
"command": "shepherd dispatch bind-root --run v657 --mode planning --confirm"
}
}),
);
assert!(bound.status.success());
let bound_output = if bound.stdout.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_slice(&bound.stdout).expect("bind hook output is JSON")
};
assert_ne!(
bound_output["hookSpecificOutput"]["permissionDecision"], "deny",
"the exact trusted bootstrap must be permitted: {bound_output}"
);
let binding: serde_json::Value = serde_json::from_slice(
&fs::read(
root.join(".shepherd/runs/v657/dispatch/.root-session.trusted-claude-session.json"),
)
.expect("trusted Claude root binding"),
)
.expect("root binding is JSON");
assert_eq!(binding["run"], "v657");
assert_eq!(binding["harness"], "claude");
assert_eq!(binding["session_id"], "trusted-claude-session");
assert_eq!(binding["mode"], "planting");
assert!(
!root
.join(".shepherd/runs/v645/dispatch/.root-session.trusted-claude-session.json")
.exists(),
"ambient v645 must not receive the explicit v657 binding"
);
let denied_execution = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "other-claude-session",
"tool_use_id": "bind-v657-execution",
"tool_name": "Bash",
"tool_input": {
"command": "shepherd dispatch bind-root --run v657 --mode execution --confirm"
}
}),
);
assert!(denied_execution.status.success());
let denied_execution: serde_json::Value =
serde_json::from_slice(&denied_execution.stdout).expect("mode mismatch denial is JSON");
assert_eq!(
denied_execution["hookSpecificOutput"]["permissionDecision"], "deny",
"planted runs must not grant execution authority: {denied_execution}"
);
assert!(
!root
.join(".shepherd/runs/v657/dispatch/.root-session.other-claude-session.json")
.exists(),
"mode mismatch must not persist a binding"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn session_start_keeps_root_unbound_and_opaque_bash_is_advisory_until_bound() {
let root = repository("root-allow");
let session = hook(
&root,
serde_json::json!({
"hook_event_name": "SessionStart",
"session_id": "claude-session-a"
}),
);
assert!(
session.status.success(),
"stderr={}",
String::from_utf8_lossy(&session.stderr)
);
let output: serde_json::Value =
serde_json::from_slice(&session.stdout).expect("SessionStart hook output is JSON");
assert_eq!(
output["hookSpecificOutput"]["hookEventName"],
"SessionStart"
);
assert!(
!root
.join(".shepherd/runs/v645/dispatch/.root-session.claude-session-a.json")
.exists(),
"SessionStart without an explicit run must not bind ambient v645"
);
let safe = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "claude-session-a",
"tool_use_id": "safe-tool-a",
"tool_name": "Bash",
"tool_input": {"command": "printf safe"}
}),
);
assert!(
safe.status.success(),
"stderr={}",
String::from_utf8_lossy(&safe.stderr)
);
let advised: serde_json::Value =
serde_json::from_slice(&safe.stdout).expect("opaque Bash response is JSON");
assert_eq!(
decision(&advised),
None,
"a session that never opened a sprint runs its own shell: {advised}"
);
assert!(
advice(&advised).contains("not bound"),
"the advisory must still name the missing explicit binding: {advised}"
);
enforce_custody_always(&root);
let refused: serde_json::Value = serde_json::from_slice(
&hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "claude-session-a",
"tool_use_id": "safe-tool-b",
"tool_name": "Bash",
"tool_input": {"command": "printf safe"}
}),
)
.stdout,
)
.expect("opaque Bash denial is JSON");
assert_eq!(
decision(&refused),
Some("deny"),
"`always` must keep refusing opaque Bash in an unbound session: {refused}"
);
assert!(
advice(&refused).contains("not bound"),
"denial must name the missing explicit binding: {refused}"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn unbound_pretooluse_names_the_remedy_without_leaking_an_errno() {
let root = repository("deny");
let denied = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "unbound-session",
"tool_use_id": "deny-tool-a",
"tool_name": "Write",
"tool_input": {"file_path": "README.md", "content": "nope"}
}),
);
assert!(
denied.status.success(),
"PreToolUse must emit a fail-closed Claude denial instead of crashing: stderr={}",
String::from_utf8_lossy(&denied.stderr)
);
let output: serde_json::Value =
serde_json::from_slice(&denied.stdout).expect("denial output is JSON");
assert_eq!(output["hookSpecificOutput"]["hookEventName"], "PreToolUse");
assert_eq!(
decision(&output),
None,
"the default posture advises a session that never opened a sprint: {output}"
);
let reason = advice(&output);
assert!(!reason.is_empty());
assert!(
!reason.contains("os error"),
"the unbound-session denial must not leak a raw errno: {reason}"
);
assert!(
!reason.contains("No such file or directory"),
"the unbound-session denial must not leak the raw io::Error text: {reason}"
);
assert!(
reason.contains("not bound") && reason.contains("/shepherd:start"),
"the unbound-session response must name the remedy: {reason}"
);
enforce_custody_always(&root);
let refused: serde_json::Value = serde_json::from_slice(
&hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "unbound-session",
"tool_use_id": "deny-tool-b",
"tool_name": "Write",
"tool_input": {"file_path": "README.md", "content": "nope"}
}),
)
.stdout,
)
.expect("denial output is JSON");
assert_eq!(decision(&refused), Some("deny"), "{refused}");
let reason = advice(&refused);
assert!(
!reason.contains("os error") && reason.contains("not bound"),
"the refusal keeps the same clean remedy text: {reason}"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[cfg(unix)]
#[test]
fn pretooluse_denial_keeps_the_errno_for_a_genuine_io_fault() {
use std::os::unix::fs::PermissionsExt;
let root = repository("genuine-io-fault");
let binding_path = root.join(".shepherd/runs/.root-session.locked-session.json");
fs::write(&binding_path, b"{}").expect("plant an unreadable root-session record");
fs::set_permissions(&binding_path, fs::Permissions::from_mode(0o000))
.expect("strip read permission to force a genuine EACCES");
let denied = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "locked-session",
"tool_use_id": "deny-tool-locked",
"tool_name": "Write",
"tool_input": {"file_path": "README.md", "content": "nope"}
}),
);
fs::set_permissions(&binding_path, fs::Permissions::from_mode(0o644))
.expect("restore permissions for cleanup");
assert!(
denied.status.success(),
"stderr={}",
String::from_utf8_lossy(&denied.stderr)
);
let output: serde_json::Value =
serde_json::from_slice(&denied.stdout).expect("denial output is JSON");
assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "deny");
let reason = output["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.expect("denial carries a reason");
assert!(
reason.contains("os error"),
"a genuine I/O fault (EACCES, not ENOENT) must keep its raw errno, \
not the unbound-session remedy: {reason}"
);
assert!(
!reason.contains("/shepherd:start"),
"a genuine I/O fault must not be mistaken for the unbound-session \
remedy: {reason}"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn malformed_input_and_unbound_subagent_events_have_safe_host_outputs() {
let root = repository("malformed-and-blocked");
let mut malformed = Command::new(binary())
.args(["hook", "--harness", "claude"])
.current_dir(&root)
.env("SHEPHERD_HOME", root.join("isolated-home"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn malformed hook");
malformed
.stdin
.take()
.expect("malformed stdin")
.write_all(b"{")
.expect("write malformed input");
let malformed = malformed.wait_with_output().expect("wait malformed hook");
assert!(malformed.status.success());
let malformed: serde_json::Value =
serde_json::from_slice(&malformed.stdout).expect("malformed input denial is JSON");
assert_eq!(
malformed["hookSpecificOutput"]["permissionDecision"],
"deny"
);
let blocked = hook(
&root,
serde_json::json!({
"hook_event_name": "SubagentStart",
"session_id": "claude-session-blocked",
"agent_id": "claude-agent-blocked",
"agent_type": "general-purpose"
}),
);
assert!(blocked.status.success());
let blocked: serde_json::Value =
serde_json::from_slice(&blocked.stdout).expect("blocked lifecycle context is JSON");
assert_eq!(
blocked["hookSpecificOutput"]["hookEventName"],
"SubagentStart"
);
assert!(
blocked["hookSpecificOutput"]["additionalContext"]
.as_str()
.is_some_and(|detail| detail.contains("rejected"))
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn subagent_stop_is_advisory_when_dispatch_cannot_be_resolved() {
let root = repository("stop-unresolved");
let stopped = hook(
&root,
serde_json::json!({
"hook_event_name": "SubagentStop",
"session_id": "claude-session-unresolved",
"agent_id": "claude-agent-unresolved",
"agent_type": "coder"
}),
);
assert!(
stopped.status.success(),
"SubagentStop must return Claude's blocking decision instead of crashing: stderr={}",
String::from_utf8_lossy(&stopped.stderr)
);
let output: serde_json::Value =
serde_json::from_slice(&stopped.stdout).expect("stop output is JSON");
assert!(
output.get("decision").is_none(),
"an unresolvable dispatch must not block a child's completion -- blocking \
discards the child's finished report and authorizes nothing: {output}"
);
let detail = output["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default();
assert!(
detail.contains("rejected"),
"advisory stop output must still explain what shepherd could not resolve: {output}"
);
assert_eq!(
output["hookSpecificOutput"]["hookEventName"],
"SubagentStop"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn direct_subagent_lifecycle_requires_native_broker() {
let root = repository("lifecycle-broker-required");
assert!(
hook(
&root,
serde_json::json!({"hook_event_name": "SessionStart", "session_id": "s1"})
)
.status
.success()
);
let started = hook(
&root,
serde_json::json!({
"hook_event_name": "SubagentStart",
"session_id": "s1",
"agent_id": "direct-child",
"agent_type": "shepherd:coder",
"shepherd_dispatch": {
"role": "coder",
"lane": "l1-engine",
"write_scope": ["crates/core/src/dispatch/**"],
"lease_ms": 60000
}
}),
);
assert!(started.status.success());
let output = format!(
"{}{}",
String::from_utf8_lossy(&started.stdout),
String::from_utf8_lossy(&started.stderr)
);
assert!(
output.contains("broker"),
"direct child rejection must name broker custody: {output}"
);
assert!(
!root
.join(".shepherd/runs/v645/dispatch/direct-child.json")
.exists()
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn a_planted_run_does_not_authorize_an_unbound_mutation_under_always() {
let root = repository("broken-namespace");
fs::write(
root.join(".shepherd/runs/v645/run.json"),
br#"{"run":"v645","status":"planted"}"#,
)
.expect("write non-executing run");
enforce_custody_always(&root);
let blocked = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "stranded-session",
"tool_use_id": "repair-tool-a",
"tool_name": "Write",
"tool_input": {"file_path": ".shepherd/runs/v645/run.json", "content": "{}"}
}),
);
assert!(blocked.status.success());
let output: serde_json::Value =
serde_json::from_slice(&blocked.stdout).expect("hook output is JSON");
assert_eq!(
decision(&output),
Some("deny"),
"a non-executing run must not create mutation authority under `always`: {output}"
);
assert!(
advice(&output).contains("not bound"),
"the denial must name the missing root binding: {output}"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn unbound_session_start_is_idempotent_for_the_same_session() {
let root = repository("session-start-idempotent");
let envelope = serde_json::json!({
"hook_event_name": "SessionStart",
"session_id": "replay-session"
});
let first = hook(&root, envelope.clone());
assert!(
first.status.success(),
"stderr={}",
String::from_utf8_lossy(&first.stderr)
);
let first_out: serde_json::Value =
serde_json::from_slice(&first.stdout).expect("first SessionStart output is JSON");
let first_detail = first_out["hookSpecificOutput"]["additionalContext"]
.as_str()
.expect("first SessionStart carries additionalContext");
assert!(
!first_detail.contains("rejected"),
"first SessionStart must not be a rejection: {first_detail}"
);
assert!(first_detail.contains("remains unbound"), "{first_detail}");
let second = hook(&root, envelope);
assert!(
second.status.success(),
"stderr={}",
String::from_utf8_lossy(&second.stderr)
);
let second_out: serde_json::Value =
serde_json::from_slice(&second.stdout).expect("second SessionStart output is JSON");
let second_detail = second_out["hookSpecificOutput"]["additionalContext"]
.as_str()
.expect("second SessionStart carries additionalContext");
assert!(
!second_detail.contains("rejected"),
"a replayed SessionStart must not be rejected: {second_detail}"
);
assert!(second_detail.contains("remains unbound"), "{second_detail}");
assert_eq!(
first_out, second_out,
"the native adapter must not need a separate replay suppression path"
);
assert!(
!root
.join(".shepherd/runs/v645/dispatch/.root-session.replay-session.json")
.exists()
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn session_start_refuses_a_replay_with_a_different_identity() {
let root = repository("session-start-different-identity");
let first = hook(
&root,
serde_json::json!({
"hook_event_name": "SessionStart",
"session_id": "reused-session",
"shepherd_dispatch": {"run": "v645", "mode": "execution"}
}),
);
assert!(
first.status.success(),
"stderr={}",
String::from_utf8_lossy(&first.stderr)
);
let first_out: serde_json::Value =
serde_json::from_slice(&first.stdout).expect("first SessionStart output is JSON");
assert!(
first_out["hookSpecificOutput"]["additionalContext"]
.as_str()
.is_some_and(|detail| detail.contains("bound root session to run v645")),
"{first_out}"
);
let conflicting = hook(
&root,
serde_json::json!({
"hook_event_name": "SessionStart",
"session_id": "reused-session",
"shepherd_dispatch": {"run": "v645", "role": "planter", "mode": "execution"}
}),
);
assert!(
conflicting.status.success(),
"stderr={}",
String::from_utf8_lossy(&conflicting.stderr)
);
let conflicting_out: serde_json::Value =
serde_json::from_slice(&conflicting.stdout).expect("conflicting output is JSON");
let detail = conflicting_out["hookSpecificOutput"]["additionalContext"]
.as_str()
.expect("conflicting SessionStart carries additionalContext");
assert!(
detail.contains("rejected"),
"a same-session replay claiming a different identity must still \
refuse: {detail}"
);
assert!(
detail.contains("different identity"),
"the refusal must name which case fired: {detail}"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn unbound_shepherd_commands_require_an_exact_bootstrap_under_always() {
let root = repository("self-repair");
enforce_custody_always(&root);
let repair = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "unbound-session",
"tool_use_id": "repair-tool-b",
"tool_name": "Bash",
"tool_input": {"command": "shepherd run transition v645 --to executing"}
}),
);
assert!(repair.status.success());
let output: serde_json::Value =
serde_json::from_slice(&repair.stdout).expect("hook output is JSON");
assert_eq!(
decision(&output),
Some("deny"),
"a broad shepherd prefix must not bypass root binding: {output}"
);
let chained = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "unbound-session",
"tool_use_id": "repair-tool-c",
"tool_name": "Bash",
"tool_input": {"command": "shepherd --version; rm -rf /tmp/shepherd-escape"}
}),
);
assert!(chained.status.success());
let chained: serde_json::Value =
serde_json::from_slice(&chained.stdout).expect("hook output is JSON");
assert_eq!(
decision(&chained),
Some("deny"),
"a chained command must not ride the self-repair exemption: {chained}"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn root_session_may_write_inside_the_repository_but_not_outside_it() {
let root = repository("root-write");
let session = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "claude-session-write",
"tool_use_id": "bind-root-write",
"tool_name": "Bash",
"tool_input": {
"command": "shepherd dispatch bind-root --run v645 --mode execution --confirm"
}
}),
);
assert!(session.status.success());
if !session.stdout.is_empty() {
let session: serde_json::Value =
serde_json::from_slice(&session.stdout).expect("bind output is JSON");
assert_ne!(session["hookSpecificOutput"]["permissionDecision"], "deny");
}
let shell = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "claude-session-write",
"tool_use_id": "root-shell",
"tool_name": "Bash",
"tool_input": {"command": "git status --short --branch"}
}),
);
assert!(
shell.status.success(),
"stderr={}",
String::from_utf8_lossy(&shell.stderr)
);
assert!(
shell.stdout.is_empty(),
"the exact bound Claude root must retain ordinary host-shell authority: {}",
String::from_utf8_lossy(&shell.stdout)
);
let inside = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "claude-session-write",
"tool_use_id": "write-inside",
"tool_name": "Write",
"tool_input": {"file_path": "notes.md", "content": "ok"}
}),
);
assert!(
inside.status.success(),
"stderr={}",
String::from_utf8_lossy(&inside.stderr)
);
assert!(
inside.stdout.is_empty(),
"a root session must be able to write inside its own repository: {}",
String::from_utf8_lossy(&inside.stdout)
);
let outside = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "claude-session-write",
"tool_use_id": "write-outside",
"tool_name": "Write",
"tool_input": {"file_path": "/etc/passwd", "content": "no"}
}),
);
assert!(outside.status.success());
let outside: serde_json::Value =
serde_json::from_slice(&outside.stdout).expect("hook output is JSON");
assert_eq!(
outside["hookSpecificOutput"]["permissionDecision"], "deny",
"a write outside the primary repository must stay refused: {outside}"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn post_tool_use_records_without_running_the_pre_flight_guard() {
let root = repository("post-tool-use");
let session = hook(
&root,
serde_json::json!({
"hook_event_name": "SessionStart",
"session_id": "claude-session-post"
}),
);
assert!(session.status.success());
let after = hook(
&root,
serde_json::json!({
"hook_event_name": "PostToolUse",
"session_id": "claude-session-post",
"tool_use_id": "post-tool-a",
"tool_name": "Write",
"tool_input": {"file_path": "/etc/passwd", "content": "no"}
}),
);
assert!(
after.status.success(),
"stderr={}",
String::from_utf8_lossy(&after.stderr)
);
let text = String::from_utf8_lossy(&after.stdout);
assert!(
!text.contains("\"permissionDecision\""),
"PostToolUse must not emit a permission decision: {text}"
);
assert!(
!text.contains("PreToolUse"),
"PostToolUse must never label its output PreToolUse: {text}"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn direct_role_starts_cannot_bypass_native_broker() {
let root = repository("role-broker-required");
assert!(
hook(
&root,
serde_json::json!({"hook_event_name": "SessionStart", "session_id": "s1"})
)
.status
.success()
);
for (agent, agent_type) in [
("cond-1", "shepherd:conductor"),
("code-1", "shepherd:coder"),
("critic-1", "shepherd:critic"),
] {
let started = hook(
&root,
serde_json::json!({
"hook_event_name": "SubagentStart",
"session_id": "s1",
"agent_id": agent,
"agent_type": agent_type
}),
);
assert!(started.status.success());
let output = format!(
"{}{}",
String::from_utf8_lossy(&started.stdout),
String::from_utf8_lossy(&started.stderr)
);
assert!(
output.contains("broker"),
"{agent_type} direct start must require broker: {output}"
);
assert!(
!root
.join(format!(".shepherd/runs/v645/dispatch/{agent}.json"))
.exists()
);
}
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn dispatch_custody_defaults_to_bound_and_always_restores_the_refusal() {
let root = repository("custody-bound");
let probe = serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "never-bound",
"tool_name": "Write",
"tool_input": {"file_path": "src/x.rs"},
});
let advised: serde_json::Value = serde_json::from_slice(&hook(&root, probe.clone()).stdout)
.expect("default custody output is JSON");
assert_eq!(
decision(&advised),
None,
"the default must not refuse a session that never opened a sprint: {advised}"
);
assert!(
advice(&advised).contains("not bound"),
"the advisory must still say what is missing: {advised}"
);
enforce_custody_always(&root);
let denied: serde_json::Value =
serde_json::from_slice(&hook(&root, probe).stdout).expect("always custody output is JSON");
assert_eq!(
decision(&denied),
Some("deny"),
"`always` must refuse an unbound mutation: {denied}"
);
assert!(
advice(&denied).contains("not bound"),
"the refusal names the same remedy: {denied}"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn open_flock_configuration_reaches_the_live_guard_engine() {
let root = repository("open-flock");
let bind = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "claude-session-flock",
"tool_use_id": "bind-flock",
"tool_name": "Bash",
"tool_input": {
"command": "shepherd dispatch bind-root --run v645 --mode execution --confirm"
}
}),
);
assert!(bind.status.success());
assert!(
root.join(".shepherd/runs/v645/dispatch/.root-session.claude-session-flock.json")
.is_file(),
"the fixture must actually bind before the guard can be consulted"
);
let probe = serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "claude-session-flock",
"tool_use_id": "flock-probe",
"tool_name": "Agent",
"tool_input": {"subagent_type": "shepherd:reviewer"},
});
fs::write(
root.join(".shepherd/shepherd.toml"),
b"[guard]\nopen_flock = false\n",
)
.expect("write guard configuration");
let closed: serde_json::Value =
serde_json::from_slice(&hook(&root, probe.clone()).stdout).expect("closed-flock is JSON");
assert_eq!(
decision(&closed),
Some("deny"),
"`open_flock = false` must refuse an invented tenth role: {closed}"
);
fs::write(
root.join(".shepherd/shepherd.toml"),
b"[guard]\nopen_flock = true\n",
)
.expect("write guard configuration");
let opened = hook(&root, probe);
assert!(opened.status.success());
let opened: serde_json::Value = if opened.stdout.is_empty() {
serde_json::json!({"hookSpecificOutput": {}})
} else {
serde_json::from_slice(&opened.stdout).expect("open-flock is JSON")
};
assert_ne!(
decision(&opened),
Some("deny"),
"`open_flock = true` must reach the engine and withhold the carrier address: {opened}"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}