Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 50 additions & 91 deletions cli/src/services/hooks/claude_mutation_scope/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,56 @@ fn identity(session_id: &str, agent_id: Option<&str>, tool_use_id: &str) -> Atte
}
}

mod ingress_conformance {
use super::*;
use crate::services::hooks::mutation_scope_ingress_conformance::{
self as conformance, mutation_scope_ingress_conformance_tests, IngressConformance,
};

struct ClaudeIngressConformance;

impl IngressConformance for ClaudeIngressConformance {
const ADAPTER: &'static str = "Claude";
const EVENT_NAME_FIELD: &'static str = HOOK_EVENT_NAME_FIELD;
const REQUIRED_START_FIELDS: &'static [&'static str] = &[
SESSION_ID_FIELD,
CWD_FIELD,
TOOL_NAME_FIELD,
TOOL_USE_ID_FIELD,
];
const OPTIONAL_START_FIELDS: &'static [(&'static str, &'static str)] = &[
(AGENT_ID_FIELD, "agent-1"),
(PROMPT_ID_FIELD, "prompt-1"),
(AGENT_TYPE_FIELD, "general-purpose"),
];
const UNSUPPORTED_EVENT_NAMES: &'static [&'static str] =
&["PostToolBatch", "PreCompact", "Notification"];

fn tracked_start() -> Map<String, Value> {
serde_json::from_str(&pre_tool_use_json(&[]))
.expect("the tracked PreToolUse fixture is a JSON object")
}

fn parse(payload: &str) -> Result<()> {
parse_claude_hook_event(payload).map(|_| ())
}

fn run(payload: &str) -> Result<String> {
run_claude_mutation_scope_from_payload(payload, None)
}

fn run_with_seams(
payload: &str,
resolve_git_dir: conformance::GitDirResolver,
seam: conformance::IngressSeam,
) -> Result<String> {
run_claude_mutation_scope_from_payload_with(payload, None, resolve_git_dir, seam)
}
}

mutation_scope_ingress_conformance_tests!(ClaudeIngressConformance);
}

#[test]
fn pre_tool_use_parses_required_and_optional_fields() {
let payload = pre_tool_use_json(&[
Expand Down Expand Up @@ -100,59 +150,6 @@ fn pre_tool_use_prompt_id_and_agent_type_are_optional() {
assert_eq!(execution.agent_type, None);
}

#[test]
fn missing_required_fields_are_rejected_without_fabricating_identity() {
for field in [
SESSION_ID_FIELD,
CWD_FIELD,
TOOL_NAME_FIELD,
TOOL_USE_ID_FIELD,
] {
let mut object: serde_json::Map<String, Value> =
serde_json::from_str(&pre_tool_use_json(&[])).unwrap();
object.remove(field);
let payload = Value::Object(object).to_string();

let error = parse_claude_hook_event(&payload).unwrap_err();
assert!(
error.to_string().contains(&format!("'{field}'")),
"expected missing-field error to name '{field}', got: {error}"
);
}
}

#[test]
fn wrong_type_required_field_is_rejected() {
let payload = pre_tool_use_json(&[(SESSION_ID_FIELD, Value::from(42))]);

let error = parse_claude_hook_event(&payload).unwrap_err();
assert!(error.to_string().contains("session_id"));
}

#[test]
fn empty_string_required_field_is_rejected() {
let payload = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String(String::new()))]);

let error = parse_claude_hook_event(&payload).unwrap_err();
assert!(error.to_string().contains("non-blank"));
}

#[test]
fn wrong_type_optional_field_is_rejected() {
let payload = pre_tool_use_json(&[(PROMPT_ID_FIELD, Value::from(1))]);

let error = parse_claude_hook_event(&payload).unwrap_err();
assert!(error.to_string().contains("prompt_id"));
}

#[test]
fn empty_optional_field_is_rejected() {
let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::String(String::new()))]);

let error = parse_claude_hook_event(&payload).unwrap_err();
assert!(error.to_string().contains("agent_id"));
}

#[test]
fn null_optional_field_is_none() {
let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::Null)]);
Expand All @@ -164,38 +161,6 @@ fn null_optional_field_is_none() {
assert_eq!(execution.identity.agent_id, None);
}

#[test]
fn empty_payload_is_rejected() {
let error = parse_claude_hook_event("").unwrap_err();
assert!(error.to_string().contains("empty payload"));

let error = parse_claude_hook_event(" ").unwrap_err();
assert!(error.to_string().contains("empty payload"));
}

#[test]
fn malformed_json_is_rejected() {
let error = parse_claude_hook_event("{not json").unwrap_err();
assert!(error.to_string().contains("valid JSON"));
}

#[test]
fn non_object_json_is_rejected() {
let error = parse_claude_hook_event("[1, 2, 3]").unwrap_err();
assert!(error.to_string().contains("JSON object"));
}

#[test]
fn unsupported_hook_event_name_is_rejected() {
let payload = pre_tool_use_json(&[(
HOOK_EVENT_NAME_FIELD,
Value::String("PostToolBatch".to_string()),
)]);

let error = parse_claude_hook_event(&payload).unwrap_err();
assert!(error.to_string().contains("unsupported hook_event_name"));
}

#[test]
fn post_tool_use_and_failure_and_permission_denied_share_tool_identity_shape() {
for event_name in [
Expand Down Expand Up @@ -2061,12 +2026,6 @@ mod driver {

remove_test_git_dir(&git_dir);
}

#[test]
fn malformed_payload_propagates_as_a_real_error_not_fail_open() {
let error = run_claude_mutation_scope_from_payload("not json", None).unwrap_err();
assert!(error.to_string().contains("valid JSON"));
}
}

mod production_regressions {
Expand Down
160 changes: 53 additions & 107 deletions cli/src/services/hooks/codex_mutation_scope/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,104 +85,68 @@ fn pre_tool_use(payload: &str) -> CodexToolExecution {
}
}

#[test]
fn ac2_empty_payload_is_rejected() {
let error = parse_codex_hook_event(" ").unwrap_err().to_string();
assert_eq!(
error,
"Invalid Codex hook event payload from STDIN: expected a JSON object, got an empty payload."
);
}
mod ingress_conformance {
use super::*;
use crate::services::hooks::mutation_scope_ingress_conformance::{
self as conformance, mutation_scope_ingress_conformance_tests, IngressConformance,
};

#[test]
fn ac2_non_object_json_is_rejected() {
for payload in ["[]", "\"PreToolUse\"", "42", "null"] {
let error = parse_codex_hook_event(payload).unwrap_err().to_string();
assert!(
error.contains("expected a JSON object"),
"payload {payload:?} produced {error:?}"
);
struct CodexIngressConformance;

fn unreachable_bash_policy(_root: &Path, command: &str) -> Result<CodexBashPolicyDecision> {
panic!("the Bash policy preflight must not run for rejected ingress (command: {command})");
}
}

#[test]
fn ac2_invalid_json_is_rejected() {
let error = parse_codex_hook_event("{not json").unwrap_err().to_string();
assert!(
error.contains("Invalid Codex hook event payload from STDIN: expected valid JSON"),
"{error:?}"
);
}
impl IngressConformance for CodexIngressConformance {
const ADAPTER: &'static str = "Codex";
const EVENT_NAME_FIELD: &'static str = HOOK_EVENT_NAME_FIELD;
const REQUIRED_START_FIELDS: &'static [&'static str] = &[
SESSION_ID_FIELD,
TURN_ID_FIELD,
CWD_FIELD,
TOOL_NAME_FIELD,
TOOL_USE_ID_FIELD,
];
const OPTIONAL_START_FIELDS: &'static [(&'static str, &'static str)] = &[
(AGENT_ID_FIELD, "01a07c24-bb59-7ca0-80f7-99cf940a486e"),
(AGENT_TYPE_FIELD, "default"),
];
const UNSUPPORTED_EVENT_NAMES: &'static [&'static str] = &[
"SessionStart",
"SubagentStart",
"UserPromptSubmit",
"PreCompact",
];

#[test]
fn ac2_unsupported_hook_event_name_is_rejected() {
for name in [
"SessionStart",
"SubagentStart",
"UserPromptSubmit",
"PreCompact",
] {
let payload =
pre_tool_use_json(&[(HOOK_EVENT_NAME_FIELD, Value::String(name.to_string()))]);
let error = parse_codex_hook_event(&payload).unwrap_err().to_string();
assert!(
error.contains(&format!("unsupported hook_event_name '{name}'")),
"{error:?}"
);
}
}
fn tracked_start() -> Map<String, Value> {
serde_json::from_str(&pre_tool_use_json(&[]))
.expect("the tracked PreToolUse fixture is a JSON object")
}

#[test]
fn ac2_missing_required_fields_are_rejected_without_fabricating_identity() {
for field in [
SESSION_ID_FIELD,
TURN_ID_FIELD,
CWD_FIELD,
TOOL_NAME_FIELD,
TOOL_USE_ID_FIELD,
] {
let mut object: Map<String, Value> = serde_json::from_str(&pre_tool_use_json(&[])).unwrap();
object.remove(field);
let payload = Value::Object(object).to_string();
fn parse(payload: &str) -> Result<()> {
parse_codex_hook_event(payload).map(|_| ())
}

let error = parse_codex_hook_event(&payload).unwrap_err().to_string();
assert!(
error.contains(&format!("'{field}'")),
"missing {field} produced {error:?}"
);
}
}
fn run(payload: &str) -> Result<String> {
run_codex_mutation_scope_from_payload(payload, None)
}

#[test]
fn ac2_blank_required_fields_are_rejected() {
for field in [SESSION_ID_FIELD, TURN_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] {
let payload = pre_tool_use_json(&[(field, Value::String(" ".to_string()))]);
let error = parse_codex_hook_event(&payload).unwrap_err().to_string();
assert!(
error.contains(&format!("field '{field}' must be a non-blank string")),
"blank {field} produced {error:?}"
);
fn run_with_seams(
payload: &str,
resolve_git_dir: conformance::GitDirResolver,
seam: conformance::IngressSeam,
) -> Result<String> {
run_codex_mutation_scope_from_payload_with_bash_policy(
payload,
None,
resolve_git_dir,
seam,
&unreachable_bash_policy,
)
}
}
}

#[test]
fn ac2_wrong_typed_fields_are_rejected() {
let payload = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::Bool(true))]);
let error = parse_codex_hook_event(&payload).unwrap_err().to_string();
assert!(
error.contains("field 'tool_use_id' must be a string"),
"{error:?}"
);
}

#[test]
fn ac2_wrong_typed_optional_agent_id_is_rejected() {
let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::Bool(false))]);
let error = parse_codex_hook_event(&payload).unwrap_err().to_string();
assert!(
error.contains("field 'agent_id' must be null, absent, or a non-blank string"),
"{error:?}"
);
mutation_scope_ingress_conformance_tests!(CodexIngressConformance);
}

#[test]
Expand Down Expand Up @@ -1786,24 +1750,6 @@ mod driver {
remove_test_git_dir(&git_dir);
}

#[test]
fn malformed_payload_propagates_as_a_real_error_not_fail_open() {
let error = run_codex_mutation_scope_from_payload("not json", None).unwrap_err();
assert!(error.to_string().contains("valid JSON"));
}

#[test]
fn unsupported_event_name_propagates_as_a_real_error() {
let payload = json!({
HOOK_EVENT_NAME_FIELD: "UserPromptSubmit",
SESSION_ID_FIELD: "session-1",
CWD_FIELD: CWD,
})
.to_string();
let error = run_codex_mutation_scope_from_payload(&payload, None).unwrap_err();
assert!(error.to_string().contains("unsupported hook_event_name"));
}

fn spawn_pre_tool_use(
git_dir: &Path,
tool_use_id: &'static str,
Expand Down
2 changes: 2 additions & 0 deletions cli/src/services/hooks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ mod claude_transforms;
mod commit_hooks;
mod conversation_trace;
mod diff_trace;
#[cfg(test)]
mod mutation_scope_ingress_conformance;
mod mutation_scope_lock;
#[cfg(test)]
mod mutation_scope_state_conformance;
Expand Down
Loading
Loading