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-codex-hook-{label}-{}-{nonce:x}",
std::process::id()
));
fs::create_dir_all(&root).expect("create fixture directory");
root
}
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());
let eol = Command::new("git")
.args(["config", "core.autocrlf", "false"])
.current_dir(&root)
.status()
.expect("pin fixture end-of-line policy");
assert!(eol.success(), "git config core.autocrlf must succeed");
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");
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 {
native_hook(root, input, "codex")
}
fn native_hook(root: &Path, input: serde_json::Value, harness: &str) -> Output {
let mut child = Command::new(binary())
.args(["hook", "--harness", harness])
.current_dir(root)
.env("SHEPHERD_HOME", root.join("isolated-home"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn shepherd codex 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")
}
fn cli(root: &Path, args: &[&str]) -> Output {
Command::new(binary())
.args(args)
.current_dir(root)
.env("SHEPHERD_HOME", root.join("isolated-home"))
.output()
.expect("run shepherd CLI")
}
fn git(root: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.expect("run git fixture command");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn linked_worktree_binding_is_exact_and_apply_patch_payloads_remain_typed() {
let primary = repository("linked-worktree-binding");
git(&primary, &["config", "user.name", "Shepherd Tests"]);
git(
&primary,
&["config", "user.email", "shepherd@example.invalid"],
);
fs::write(primary.join("plan.md"), "plan\n").expect("write fixture plan");
git(&primary, &["add", "plan.md"]);
git(&primary, &["commit", "-qm", "fixture"]);
let linked_a = fixture_dir("linked-worktree-a");
let linked_b = fixture_dir("linked-worktree-b");
fs::remove_dir(&linked_a).expect("worktree target must be absent");
fs::remove_dir(&linked_b).expect("worktree target must be absent");
git(
&primary,
&[
"worktree",
"add",
"-qb",
"linked-worktree-a",
linked_a.to_str().expect("UTF-8 worktree path"),
],
);
git(
&primary,
&[
"worktree",
"add",
"-qb",
"linked-worktree-b",
linked_b.to_str().expect("UTF-8 worktree path"),
],
);
let bind = hook(
&linked_a,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "linked-root-session",
"tool_use_id": "bind-linked-root",
"tool_name": "Bash",
"tool_input": {
"command": "shepherd dispatch bind-root --run v645 --mode execution --confirm"
}
}),
);
assert!(
bind.status.success(),
"stderr={}",
String::from_utf8_lossy(&bind.stderr)
);
let bind: serde_json::Value =
serde_json::from_slice(&bind.stdout).expect("binding output is JSON");
assert!(
bind["hookSpecificOutput"]["additionalContext"]
.as_str()
.is_some_and(|detail| detail.contains("bound trusted root session")),
"{bind}"
);
let shell = hook(
&linked_a,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "linked-root-session",
"tool_use_id": "linked-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 Codex root must retain ordinary host-shell authority: {}",
String::from_utf8_lossy(&shell.stdout)
);
let absolute_patch = format!(
"*** Begin Patch\n*** Update File: {}\n*** End Patch",
linked_a.join("plan.md").display()
);
let object = hook(
&linked_a,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "linked-root-session",
"tool_use_id": "linked-object-patch",
"tool_name": "apply_patch",
"tool_input": {"patch": absolute_patch}
}),
);
assert!(
object.status.success(),
"stderr={}",
String::from_utf8_lossy(&object.stderr)
);
assert!(
object.stdout.is_empty(),
"a bound worktree path in root markdown scope must be allowed: {}",
String::from_utf8_lossy(&object.stdout)
);
let freeform = hook(
&linked_a,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "linked-root-session",
"tool_use_id": "linked-freeform-patch",
"tool_name": "apply_patch",
"tool_input": "*** Begin Patch\n*** Update File: plan.md\n*** End Patch"
}),
);
assert!(
freeform.status.success(),
"stderr={}",
String::from_utf8_lossy(&freeform.stderr)
);
assert!(
freeform.stdout.is_empty(),
"a canonical freeform apply_patch payload must be allowed: {}",
String::from_utf8_lossy(&freeform.stdout)
);
let sibling = hook(
&linked_b,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "linked-root-session",
"tool_use_id": "sibling-replay",
"tool_name": "apply_patch",
"tool_input": {"patch": "*** Begin Patch\n*** Update File: plan.md\n*** End Patch"}
}),
);
assert!(
sibling.status.success(),
"stderr={}",
String::from_utf8_lossy(&sibling.stderr)
);
let sibling: serde_json::Value =
serde_json::from_slice(&sibling.stdout).expect("sibling denial is JSON");
assert_eq!(
sibling["hookSpecificOutput"]["permissionDecision"], "deny",
"a sibling worktree must not replay the bound session"
);
let primary_escape = hook(
&linked_a,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "linked-root-session",
"tool_use_id": "primary-escape",
"tool_name": "apply_patch",
"tool_input": {"file_path": primary.join("plan.md")}
}),
);
assert!(primary_escape.status.success());
let primary_escape: serde_json::Value =
serde_json::from_slice(&primary_escape.stdout).expect("primary denial is JSON");
assert_eq!(
primary_escape["hookSpecificOutput"]["permissionDecision"], "deny",
"the linked binding must not authorize the primary checkout"
);
#[cfg(unix)]
{
std::os::unix::fs::symlink(primary.join("plan.md"), linked_a.join("escape.md"))
.expect("create escaping write target");
let symlink_escape = hook(
&linked_a,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "linked-root-session",
"tool_use_id": "symlink-escape",
"tool_name": "apply_patch",
"tool_input": {"patch": "*** Begin Patch\n*** Update File: escape.md\n*** End Patch"}
}),
);
assert!(symlink_escape.status.success());
let symlink_escape: serde_json::Value =
serde_json::from_slice(&symlink_escape.stdout).expect("symlink escape denial is JSON");
assert_eq!(
symlink_escape["hookSpecificOutput"]["permissionDecision"], "deny",
"the linked binding must not authorize a symlink escape"
);
}
fs::remove_dir_all(linked_a).expect("remove linked A");
fs::remove_dir_all(linked_b).expect("remove linked B");
fs::remove_dir_all(primary).expect("remove primary fixture");
}
#[test]
fn exact_spawn_bootstrap_initializes_an_absent_run_and_binds_the_trusted_codex_session() {
let root = repository("explicit-spawn-bootstrap");
fs::remove_dir_all(root.join(".shepherd")).expect("remove untrusted ambient namespace");
let project_init = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "trusted-codex-session",
"tool_use_id": "init-project",
"tool_name": "Bash",
"tool_input": {"command": "shepherd init --confirm"}
}),
);
assert!(project_init.status.success());
let project_init_output: serde_json::Value =
serde_json::from_slice(&project_init.stdout).expect("project init hook output is JSON");
assert_ne!(
project_init_output["hookSpecificOutput"]["permissionDecision"], "deny",
"the exact native project initializer must be usable before project discovery: \
{project_init_output}"
);
let initialized_project = cli(&root, &["init", "--confirm"]);
assert!(
initialized_project.status.success(),
"stderr={}",
String::from_utf8_lossy(&initialized_project.stderr)
);
assert!(root.join(".shepherd/project.json").is_file());
fs::create_dir_all(root.join(".shepherd/runs/v657")).expect("v657 source directory");
fs::write(root.join(".shepherd/runs/v657/seed.md"), "# seed\n").expect("plant source seed");
let missing_show = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "trusted-codex-session",
"tool_use_id": "show-absent-v657",
"tool_name": "Bash",
"tool_input": {"command": "shepherd run show v657 --json"}
}),
);
assert!(missing_show.status.success());
let missing_show_output: serde_json::Value =
serde_json::from_slice(&missing_show.stdout).expect("absent-run show hook output is JSON");
assert_ne!(
missing_show_output["hookSpecificOutput"]["permissionDecision"], "deny",
"the exact read-only show command must execute so Native can report an absent run: \
{missing_show_output}"
);
let missing = cli(&root, &["run", "show", "v657", "--json"]);
assert!(
!missing.status.success(),
"Native must report the explicitly selected run as absent"
);
assert!(
String::from_utf8_lossy(&missing.stderr).contains("no such run: v657"),
"unexpected absent-run stderr: {}",
String::from_utf8_lossy(&missing.stderr)
);
let init = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "trusted-codex-session",
"tool_use_id": "init-v657",
"tool_name": "Bash",
"tool_input": {
"command": "shepherd run init v657 --branch v6.5.7 --base main --version 6.5.7"
}
}),
);
assert!(init.status.success());
let init_output: serde_json::Value =
serde_json::from_slice(&init.stdout).expect("init hook output is JSON");
assert_ne!(
init_output["hookSpecificOutput"]["permissionDecision"], "deny",
"the exact typed native run initializer must be usable while unbound: {init_output}"
);
let initialized = cli(
&root,
&[
"run",
"init",
"v657",
"--branch",
"v6.5.7",
"--base",
"main",
"--version",
"6.5.7",
],
);
assert!(
initialized.status.success(),
"stderr={}",
String::from_utf8_lossy(&initialized.stderr)
);
let state: serde_json::Value = serde_json::from_slice(
&fs::read(root.join(".shepherd/runs/v657/run.json")).expect("native run state"),
)
.expect("run state is JSON");
assert_eq!(state["run"], "v657");
assert_eq!(state["status"], "planted");
assert_eq!(state["branch"], "v6.5.7");
assert_eq!(state["base"], "main");
assert_eq!(
fs::read_to_string(root.join(".shepherd/runs/v657/seed.md")).expect("preserved seed"),
"# seed\n",
"native initialization must preserve authoritative source documents"
);
fs::create_dir_all(root.join(".shepherd/runs/v646")).expect("corrupt sibling directory");
fs::write(root.join(".shepherd/runs/v646/run.json"), b"{")
.expect("corrupt sibling run document");
let show = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "trusted-codex-session",
"tool_use_id": "show-v657",
"tool_name": "Bash",
"tool_input": {"command": "shepherd run show v657 --json"}
}),
);
assert!(show.status.success());
let show_output: serde_json::Value =
serde_json::from_slice(&show.stdout).expect("show hook output is JSON");
assert_ne!(
show_output["hookSpecificOutput"]["permissionDecision"], "deny",
"explicit selected-run inspection must ignore corrupt siblings: {show_output}"
);
let shown = cli(&root, &["run", "show", "v657", "--json"]);
assert!(
shown.status.success(),
"stderr={}",
String::from_utf8_lossy(&shown.stderr)
);
let shown: serde_json::Value = serde_json::from_slice(&shown.stdout).expect("shown run JSON");
assert_eq!(shown["run"], "v657");
assert_eq!(shown["status"], "planted");
let bind = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "trusted-codex-session",
"tool_use_id": "bind-v657",
"tool_name": "Bash",
"tool_input": {
"command": "shepherd dispatch bind-root --run v657 --mode planning --confirm"
}
}),
);
assert!(bind.status.success());
let bind_output = if bind.stdout.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_slice(&bind.stdout).expect("bind hook output is JSON")
};
assert_ne!(
bind_output["hookSpecificOutput"]["permissionDecision"], "deny",
"the exact bootstrap command must be allowed after it binds: {bind_output}"
);
let binding_path =
root.join(".shepherd/runs/v657/dispatch/.root-session.trusted-codex-session.json");
let binding: serde_json::Value =
serde_json::from_slice(&fs::read(&binding_path).expect("trusted Codex session binding"))
.expect("root binding is JSON");
assert_eq!(binding["run"], "v657");
assert_eq!(binding["harness"], "codex");
assert_eq!(binding["session_id"], "trusted-codex-session");
assert_eq!(binding["mode"], "planting");
let handshake = cli(
&root,
&[
"dispatch",
"bind-root",
"--run",
"v657",
"--mode",
"planning",
"--confirm",
],
);
assert!(
handshake.status.success(),
"bounded bind-root CLI handshake failed: {}",
String::from_utf8_lossy(&handshake.stderr)
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn same_root_continuation_moves_authority_to_v657_and_rejects_v645_replay() {
let root = repository("cross-run-root-continuation");
let old = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "continuing-codex-root",
"tool_use_id": "bind-old-v645",
"tool_name": "Bash",
"tool_input": {
"command": "shepherd dispatch bind-root --run v645 --mode execution --confirm"
}
}),
);
assert!(
old.status.success(),
"stderr={}",
String::from_utf8_lossy(&old.stderr)
);
let old_output: serde_json::Value =
serde_json::from_slice(&old.stdout).expect("initial bind output JSON");
assert_ne!(
old_output["hookSpecificOutput"]["permissionDecision"], "deny",
"initial bind failed: {old_output}"
);
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");
let next = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "continuing-codex-root",
"tool_use_id": "bind-next-v657",
"tool_name": "Bash",
"tool_input": {
"command": "shepherd dispatch bind-root --run v657 --mode planning --confirm"
}
}),
);
assert!(
next.status.success(),
"stderr={}",
String::from_utf8_lossy(&next.stderr)
);
let next_output: serde_json::Value =
serde_json::from_slice(&next.stdout).expect("continuation bind output JSON");
assert_ne!(
next_output["hookSpecificOutput"]["permissionDecision"], "deny",
"continuation bind failed: {next_output}"
);
let current: serde_json::Value = serde_json::from_slice(
&fs::read(root.join(".shepherd/runs/.root-session.continuing-codex-root.json"))
.expect("current root index"),
)
.expect("current root JSON");
assert_eq!(current["run"], "v657");
assert_eq!(current["mode"], "planting");
assert!(
root.join(".shepherd/runs/v645/dispatch/.root-session.continuing-codex-root.json")
.is_file(),
"old per-run binding remains immutable evidence"
);
let replay = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "continuing-codex-root",
"tool_use_id": "replay-old-v645",
"tool_name": "Bash",
"tool_input": {
"command": "shepherd dispatch bind-root --run v645 --mode execution --confirm"
}
}),
);
assert!(replay.status.success());
let replay: serde_json::Value =
serde_json::from_slice(&replay.stdout).expect("replay denial JSON");
assert_eq!(replay["hookSpecificOutput"]["permissionDecision"], "deny");
let current_after: serde_json::Value = serde_json::from_slice(
&fs::read(root.join(".shepherd/runs/.root-session.continuing-codex-root.json"))
.expect("current root remains"),
)
.expect("current root JSON");
assert_eq!(current_after["run"], "v657");
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn exact_run_show_rejects_a_malformed_selected_run() {
let root = repository("malformed-selected-run");
fs::create_dir_all(root.join(".shepherd/runs/v657")).expect("v657 source directory");
fs::write(root.join(".shepherd/runs/v657/run.json"), b"{")
.expect("malformed selected run document");
let show = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "unbound-codex-session",
"tool_use_id": "show-malformed-v657",
"tool_name": "Bash",
"tool_input": {"command": "shepherd run show v657 --json"}
}),
);
assert!(show.status.success());
let output: serde_json::Value =
serde_json::from_slice(&show.stdout).expect("selected-run denial is JSON");
assert_eq!(
output["hookSpecificOutput"]["permissionDecision"], "deny",
"malformed selected-run authority must fail closed: {output}"
);
assert!(
output["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.is_some_and(|reason| reason.contains("invalid run document")),
"denial must name the malformed selected run: {output}"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn unbound_codex_rejects_mismatched_or_noncanonical_bootstrap_commands_under_always() {
let root = repository("reject-bootstrap-variants");
enforce_custody_always(&root);
for (tool_use_id, command) in [
(
"mismatched-version",
"shepherd run init v657 --branch v6.5.8 --base main --version 6.5.8",
),
(
"forced-noncanonical",
"shepherd run init release-657 --branch v6.5.7 --base main --version 6.5.7 --force",
),
(
"broad-self-repair",
"shepherd run transition v657 --to executing",
),
("malformed-run-show", "shepherd run show ../v657 --json"),
(
"missing-confirmation",
"shepherd dispatch bind-root --run v657 --mode planning",
),
(
"chained-bootstrap",
"shepherd dispatch bind-root --run v657 --mode planning --confirm; printf escaped",
),
] {
let denied = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "unbound-codex-session",
"tool_use_id": tool_use_id,
"tool_name": "Bash",
"tool_input": {"command": command}
}),
);
assert!(denied.status.success());
let output: serde_json::Value =
serde_json::from_slice(&denied.stdout).expect("denial output is JSON");
assert_eq!(
output["hookSpecificOutput"]["permissionDecision"], "deny",
"unbound command must not receive bootstrap authority: {command}: {output}"
);
}
assert!(
!root.join(".shepherd/runs/v657/run.json").exists(),
"denied initializers must not create a run"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn unscaffolded_codex_allows_only_the_exact_project_initializer() {
let root = repository("reject-project-init-variants");
fs::remove_dir_all(root.join(".shepherd")).expect("remove project namespace");
for (tool_use_id, command) in [
("wrapped-project-init", "env shepherd init --confirm"),
("extra-project-init", "shepherd init --confirm --no-doctor"),
(
"chained-project-init",
"shepherd init --confirm; printf escaped",
),
("unconfirmed-project-init", "shepherd init"),
] {
let denied = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "unscaffolded-codex-session",
"tool_use_id": tool_use_id,
"tool_name": "Bash",
"tool_input": {"command": command}
}),
);
assert!(denied.status.success());
let output: serde_json::Value =
serde_json::from_slice(&denied.stdout).expect("denial output is JSON");
assert_eq!(
output["hookSpecificOutput"]["permissionDecision"], "deny",
"unscaffolded project command must fail closed: {command}: {output}"
);
}
assert!(
!root.join(".shepherd").exists(),
"denied project initializers must not create project state"
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn session_start_keeps_codex_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": "codex-session-a",
"provider_version": "0.147.0"
}),
);
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.codex-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": "codex-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 Codex 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": "codex-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"),
"Codex root shell capability must not bypass structured scope: {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_codex_mutation_is_advisory_and_refused_under_always() {
let root = repository("deny");
let denied = hook(
&root,
serde_json::json!({
"hook_event_name": "PreToolUse",
"session_id": "unbound-codex-session",
"tool_use_id": "deny-tool-a",
"tool_name": "apply_patch",
"tool_input": {"patch": "*** Begin Patch\n*** End Patch"}
}),
);
assert!(
denied.status.success(),
"PreToolUse must emit a fail-closed Codex denial: 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 advises a session that never opened a sprint: {output}"
);
assert!(!advice(&output).is_empty());
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-codex-session",
"tool_use_id": "deny-tool-b",
"tool_name": "apply_patch",
"tool_input": {"patch": "*** Begin Patch\n*** End Patch"}
}),
)
.stdout,
)
.expect("denial output is JSON");
assert_eq!(
decision(&refused),
Some("deny"),
"`always` must keep refusing an unbound Codex mutation: {refused}"
);
assert!(!advice(&refused).is_empty());
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn malformed_codex_input_is_a_fail_closed_host_response() {
let root = repository("malformed");
let mut malformed = Command::new(binary())
.args(["hook", "--harness", "codex"])
.current_dir(&root)
.env("SHEPHERD_HOME", root.join("isolated-home"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn malformed Codex 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"
);
assert!(
malformed["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.is_some_and(|reason| reason.contains("Codex"))
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn codex_subagent_lifecycle_is_rejected_without_a_trusted_host_contract() {
let root = repository("lifecycle");
let session = hook(
&root,
serde_json::json!({
"hook_event_name": "SessionStart",
"session_id": "codex-session-lifecycle"
}),
);
assert!(session.status.success());
let started = hook(
&root,
serde_json::json!({
"hook_event_name": "SubagentStart",
"session_id": "codex-session-lifecycle",
"agent_id": "codex-agent-lifecycle",
"agent_type": "worker",
"model": "gpt-5.5",
"permission_mode": "default",
"cwd": root,
"transcript_path": null,
"turn_id": "turn-lifecycle",
}),
);
assert!(
started.status.success(),
"stderr={}",
String::from_utf8_lossy(&started.stderr)
);
let started_output: serde_json::Value =
serde_json::from_slice(&started.stdout).expect("SubagentStart output is JSON");
assert!(
started_output["hookSpecificOutput"]["additionalContext"]
.as_str()
.is_some_and(|detail| detail.contains("native broker")),
"unexpected output: {}",
started_output
);
assert!(
!root
.join(".shepherd/runs/v645/dispatch/codex-agent-lifecycle.json")
.exists()
);
let stopped = hook(
&root,
serde_json::json!({
"hook_event_name": "SubagentStop",
"session_id": "codex-session-lifecycle",
"agent_id": "codex-agent-lifecycle",
"agent_type": "worker",
"model": "gpt-5.5",
"permission_mode": "default",
"cwd": root,
"transcript_path": null,
"turn_id": "turn-lifecycle",
}),
);
assert!(
stopped.status.success(),
"stderr={}",
String::from_utf8_lossy(&stopped.stderr)
);
let stopped: serde_json::Value =
serde_json::from_slice(&stopped.stdout).expect("SubagentStop output is JSON");
let stopped_detail = stopped["reason"]
.as_str()
.or_else(|| stopped["hookSpecificOutput"]["additionalContext"].as_str())
.unwrap_or("");
assert!(
stopped_detail.contains("native broker"),
"unexpected output: {}",
stopped
);
fs::remove_dir_all(root).expect("remove fixture directory");
}
#[test]
fn native_child_binding_requires_the_broker_instead_of_hook_scope_json() {
let root = repository("missing-scope");
let started = hook(
&root,
serde_json::json!({
"hook_event_name": "SubagentStart",
"session_id": "codex-session-missing-scope",
"agent_id": "codex-agent-missing-scope",
"agent_type": "worker",
"shepherd_dispatch": {"write_scope": ["crates/**"]}
}),
);
assert!(started.status.success());
let output: serde_json::Value = serde_json::from_slice(&started.stdout).expect("start output");
assert!(
output["hookSpecificOutput"]["additionalContext"]
.as_str()
.is_some_and(|reason| reason.contains("native broker"))
);
assert!(
!root
.join(".shepherd/runs/v645/dispatch/codex-agent-missing-scope.json")
.exists()
);
fs::remove_dir_all(root).expect("remove fixture directory");
}