use std::io::Write;
use crate::Decision;
use crate::hook_output;
use super::HookAgent;
pub(super) fn emit_decision<W1: Write, W2: Write>(
agent: HookAgent,
decision: &Decision,
stdout: &mut W1,
stderr: &mut W2,
) -> u8 {
let adapted = adapt_hook_decision(agent, decision);
let serialised = match agent {
HookAgent::Copilot => {
hook_output::copilot::from_decision(&adapted).map(|r| serde_json::to_string(&r))
},
HookAgent::Cline => Some(serde_json::to_string(&hook_output::cline::from_decision(
&adapted,
))),
HookAgent::Cursor => Some(serde_json::to_string(&hook_output::cursor::from_decision(
&adapted,
))),
HookAgent::Pi => Some(serde_json::to_string(&hook_output::pi::from_decision(
&adapted,
))),
HookAgent::ClaudeCode | HookAgent::Codex => {
render_hook_response(agent, &adapted).map(|r| serde_json::to_string(&r))
},
HookAgent::Kiro => None,
};
if let Some(result) = serialised {
match result {
Ok(body) => {
let _ = writeln!(stdout, "{body}");
},
Err(err) => {
let _ = writeln!(stderr, "ptuf: failed to serialise hook response: {err}");
return 1;
},
}
}
if let Some(reason) = adapted.reason() {
let _ = writeln!(stderr, "{reason}");
}
decision_exit_code(agent, &adapted)
}
pub(super) fn decision_label(decision: &Decision) -> &'static str {
match decision {
Decision::Allow => "allow",
Decision::Monitor { .. } => "monitor",
Decision::Ask { .. } => "ask",
Decision::Deny { .. } => "deny",
}
}
pub(super) fn render_hook_response(
agent: HookAgent,
decision: &Decision,
) -> Option<hook_output::HookResponse> {
match agent {
HookAgent::ClaudeCode => hook_output::claude_code::from_decision(decision),
HookAgent::Codex => hook_output::codex::from_decision(decision),
HookAgent::Copilot => None,
HookAgent::Kiro => None,
HookAgent::Cline => None,
HookAgent::Cursor => None,
HookAgent::Pi => None,
}
}
pub(super) fn adapt_hook_decision(agent: HookAgent, decision: &Decision) -> Decision {
match (agent, decision) {
(HookAgent::Codex, Decision::Ask { rule_id, reason }) => Decision::Deny {
rule_id: rule_id.clone(),
reason: hook_output::codex::deny_reason_for_ask(reason),
},
(HookAgent::Copilot, Decision::Ask { rule_id, reason }) => Decision::Deny {
rule_id: rule_id.clone(),
reason: hook_output::copilot::deny_reason_for_ask(reason),
},
(HookAgent::Kiro, Decision::Ask { rule_id, reason }) => Decision::Deny {
rule_id: rule_id.clone(),
reason: hook_output::kiro::deny_reason_for_ask(reason),
},
(HookAgent::Cline, Decision::Ask { rule_id, reason }) => Decision::Deny {
rule_id: rule_id.clone(),
reason: hook_output::cline::deny_reason_for_ask(reason),
},
_ => decision.clone(),
}
}
pub(super) fn decision_exit_code(agent: HookAgent, decision: &Decision) -> u8 {
if matches!(agent, HookAgent::Copilot | HookAgent::Cline) {
return 0;
}
match (agent, decision) {
(_, Decision::Deny { .. }) => 2,
(HookAgent::Codex | HookAgent::Kiro, Decision::Ask { .. }) => 2,
_ => 0,
}
}
#[cfg(test)]
mod tests {
use crate::Decision;
use super::super::HookAgent;
use super::{
adapt_hook_decision, decision_exit_code, decision_label, emit_decision,
render_hook_response,
};
#[test]
fn decision_label_covers_all_variants() {
assert_eq!(decision_label(&Decision::Allow), "allow");
assert_eq!(
decision_label(&Decision::Monitor {
rule_id: "x".into()
}),
"monitor"
);
assert_eq!(
decision_label(&Decision::Ask {
rule_id: "x".into(),
reason: "r".into(),
}),
"ask"
);
assert_eq!(
decision_label(&Decision::Deny {
rule_id: "x".into(),
reason: "r".into(),
}),
"deny"
);
}
#[test]
fn emit_decision_writes_ask_envelope_with_zero_exit() {
let decision = Decision::Ask {
rule_id: "core.test.ask".into(),
reason: "please confirm".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::ClaudeCode, &decision, &mut out, &mut err);
assert_eq!(code, 0);
let out_s = String::from_utf8_lossy(&out);
assert!(out_s.contains("\"permissionDecision\":\"ask\""));
assert!(String::from_utf8_lossy(&err).contains("please confirm"));
}
#[test]
fn hook_codex_demotes_ask_to_deny_via_emit_decision() {
let decision = Decision::Ask {
rule_id: "core.test.ask".into(),
reason: "please confirm".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Codex, &decision, &mut out, &mut err);
assert_eq!(code, 2, "Codex must demote Ask to deny exit code");
let out_s = String::from_utf8_lossy(&out);
assert!(
out_s.contains("\"permissionDecision\":\"deny\""),
"stdout: {out_s}"
);
let err_s = String::from_utf8_lossy(&err);
assert!(err_s.contains("please confirm"), "stderr: {err_s}");
assert!(
err_s.contains("Codex PreToolUse cannot prompt interactively"),
"stderr should explain Codex demotion: {err_s}"
);
}
#[test]
fn render_hook_response_dispatches_to_codex_adapter_for_ask() {
let decision = Decision::Ask {
rule_id: "core.test.ask".into(),
reason: "please confirm".into(),
};
let claude =
render_hook_response(HookAgent::ClaudeCode, &decision).expect("claude-code envelope");
let adapted = adapt_hook_decision(HookAgent::Codex, &decision);
let codex = render_hook_response(HookAgent::Codex, &adapted).expect("codex envelope");
let claude_json = serde_json::to_string(&claude).unwrap();
let codex_json = serde_json::to_string(&codex).unwrap();
assert_ne!(
claude_json, codex_json,
"Codex envelope must differ from Claude Code for Ask"
);
assert!(
codex_json.contains("\"permissionDecision\":\"deny\""),
"codex envelope must demote to deny: {codex_json}"
);
}
#[test]
fn decision_exit_code_matrix_per_agent() {
let allow = Decision::Allow;
let monitor = Decision::Monitor {
rule_id: "x".into(),
};
let ask = Decision::Ask {
rule_id: "x".into(),
reason: "r".into(),
};
let deny = Decision::Deny {
rule_id: "x".into(),
reason: "r".into(),
};
let cases: &[(HookAgent, &Decision, u8)] = &[
(HookAgent::ClaudeCode, &allow, 0),
(HookAgent::ClaudeCode, &ask, 0),
(HookAgent::ClaudeCode, &deny, 2),
(HookAgent::Codex, &ask, 2),
(HookAgent::Kiro, &allow, 0),
(HookAgent::Kiro, &monitor, 0),
(HookAgent::Kiro, &ask, 2),
(HookAgent::Kiro, &deny, 2),
(HookAgent::Cline, &allow, 0),
(HookAgent::Cline, &ask, 0),
(HookAgent::Cline, &deny, 0),
(HookAgent::Cursor, &allow, 0),
(HookAgent::Cursor, &monitor, 0),
(HookAgent::Cursor, &ask, 0),
(HookAgent::Cursor, &deny, 2),
(HookAgent::Pi, &allow, 0),
(HookAgent::Pi, &monitor, 0),
(HookAgent::Pi, &ask, 0),
(HookAgent::Pi, &deny, 2),
];
for (agent, decision, expect) in cases {
assert_eq!(
decision_exit_code(*agent, decision),
*expect,
"agent={agent:?} decision={decision:?}"
);
}
}
#[test]
fn kiro_deny_uses_exit_2_and_stderr_only() {
let decision = Decision::Deny {
rule_id: "core.filesystem.destructive-rm".into(),
reason: "blocked".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Kiro, &decision, &mut out, &mut err);
assert_eq!(code, 2);
assert!(out.is_empty(), "Kiro must not write stdout, got: {out:?}");
assert!(String::from_utf8_lossy(&err).contains("blocked"));
}
#[test]
fn kiro_ask_is_demoted_to_deny() {
let decision = Decision::Ask {
rule_id: "core.test.ask".into(),
reason: "please confirm".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Kiro, &decision, &mut out, &mut err);
assert_eq!(code, 2, "Kiro must demote Ask to deny exit code");
assert!(out.is_empty());
let err_s = String::from_utf8_lossy(&err);
assert!(err_s.contains("please confirm"), "stderr: {err_s}");
assert!(
err_s.contains("Kiro CLI PreToolUse hooks do not define an interactive ask channel"),
"stderr should explain Kiro demotion: {err_s}"
);
}
#[test]
fn kiro_allow_writes_nothing_and_exits_zero() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Kiro, &Decision::Allow, &mut out, &mut err);
assert_eq!(code, 0);
assert!(out.is_empty());
assert!(err.is_empty());
}
#[test]
fn kiro_monitor_writes_nothing_and_exits_zero() {
let monitor = Decision::Monitor {
rule_id: "core.test.monitor".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Kiro, &monitor, &mut out, &mut err);
assert_eq!(code, 0);
assert!(out.is_empty());
assert!(err.is_empty());
}
#[test]
fn render_hook_response_is_none_for_kiro() {
let decision = Decision::Deny {
rule_id: "core.x".into(),
reason: "r".into(),
};
assert!(render_hook_response(HookAgent::Kiro, &decision).is_none());
}
#[test]
fn cline_deny_emits_cancel_json_with_zero_exit() {
let decision = Decision::Deny {
rule_id: "core.filesystem.destructive-rm".into(),
reason: "blocked".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Cline, &decision, &mut out, &mut err);
assert_eq!(code, 0, "Cline deny must exit 0 to stay fail-closed");
let json: serde_json::Value =
serde_json::from_slice(&out).expect("Cline stdout must be JSON");
assert_eq!(json["cancel"], true);
assert_eq!(json["errorMessage"], "blocked");
assert!(json.get("shouldContinue").is_none());
assert!(String::from_utf8_lossy(&err).contains("blocked"));
}
#[test]
fn cline_allow_emits_empty_object_with_zero_exit() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Cline, &Decision::Allow, &mut out, &mut err);
assert_eq!(code, 0);
assert_eq!(String::from_utf8_lossy(&out), "{}\n");
assert!(err.is_empty());
}
#[test]
fn cline_monitor_emits_empty_object_with_zero_exit() {
let monitor = Decision::Monitor {
rule_id: "core.test.monitor".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Cline, &monitor, &mut out, &mut err);
assert_eq!(code, 0);
assert_eq!(String::from_utf8_lossy(&out), "{}\n");
}
#[test]
fn cline_ask_is_demoted_to_cancel_with_zero_exit() {
let decision = Decision::Ask {
rule_id: "core.test.ask".into(),
reason: "please confirm".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Cline, &decision, &mut out, &mut err);
assert_eq!(code, 0, "Cline must demote Ask without a non-zero exit");
let json: serde_json::Value =
serde_json::from_slice(&out).expect("Cline stdout must be JSON");
assert_eq!(json["cancel"], true);
let msg = json["errorMessage"].as_str().expect("errorMessage");
assert!(msg.contains("please confirm"));
assert!(msg.contains("Cline PreToolUse file hooks"));
}
#[test]
fn render_hook_response_is_none_for_cline() {
let decision = Decision::Deny {
rule_id: "core.x".into(),
reason: "r".into(),
};
assert!(render_hook_response(HookAgent::Cline, &decision).is_none());
}
#[test]
fn cursor_deny_emits_permission_json_with_exit_2() {
let decision = Decision::Deny {
rule_id: "core.filesystem.destructive-rm".into(),
reason: "blocked".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Cursor, &decision, &mut out, &mut err);
assert_eq!(code, 2, "Cursor deny exits 2");
let json: serde_json::Value =
serde_json::from_slice(&out).expect("Cursor stdout must be JSON");
assert_eq!(json["permission"], "deny");
assert!(
json.get("hookSpecificOutput").is_none(),
"Cursor must emit a bare envelope"
);
assert!(String::from_utf8_lossy(&err).contains("blocked"));
}
#[test]
fn cursor_ask_is_preserved_with_exit_0() {
let decision = Decision::Ask {
rule_id: "core.test.ask".into(),
reason: "please confirm".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Cursor, &decision, &mut out, &mut err);
assert_eq!(code, 0, "Cursor Ask must exit 0, not demote to deny");
let json: serde_json::Value =
serde_json::from_slice(&out).expect("Cursor stdout must be JSON");
assert_eq!(json["permission"], "ask");
assert!(String::from_utf8_lossy(&err).contains("please confirm"));
}
#[test]
fn cursor_allow_emits_explicit_allow_with_zero_exit() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Cursor, &Decision::Allow, &mut out, &mut err);
assert_eq!(code, 0);
let json: serde_json::Value =
serde_json::from_slice(&out).expect("Cursor stdout must be JSON");
assert_eq!(json["permission"], "allow");
assert!(json.get("user_message").is_none());
assert!(json.get("agent_message").is_none());
assert!(err.is_empty());
}
#[test]
fn render_hook_response_is_none_for_cursor() {
let decision = Decision::Deny {
rule_id: "core.x".into(),
reason: "r".into(),
};
assert!(render_hook_response(HookAgent::Cursor, &decision).is_none());
}
#[test]
fn pi_deny_emits_decision_json_with_exit_2() {
let decision = Decision::Deny {
rule_id: "core.filesystem.destructive-rm".into(),
reason: "blocked".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Pi, &decision, &mut out, &mut err);
assert_eq!(code, 2, "Pi deny exits 2");
let json: serde_json::Value = serde_json::from_slice(&out).expect("Pi stdout must be JSON");
assert_eq!(json["decision"], "deny");
assert_eq!(json["rule_id"], "core.filesystem.destructive-rm");
assert_eq!(json["reason"], "blocked");
assert!(
json.get("hookSpecificOutput").is_none(),
"Pi must emit a bare envelope"
);
assert!(String::from_utf8_lossy(&err).contains("blocked"));
}
#[test]
fn pi_ask_is_preserved_with_exit_0() {
let decision = Decision::Ask {
rule_id: "core.test.ask".into(),
reason: "please confirm".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Pi, &decision, &mut out, &mut err);
assert_eq!(code, 0, "Pi Ask must exit 0, not demote to deny");
let json: serde_json::Value = serde_json::from_slice(&out).expect("Pi stdout must be JSON");
assert_eq!(json["decision"], "ask");
assert!(String::from_utf8_lossy(&err).contains("please confirm"));
}
#[test]
fn pi_allow_emits_explicit_allow_with_zero_exit() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Pi, &Decision::Allow, &mut out, &mut err);
assert_eq!(code, 0);
let json: serde_json::Value = serde_json::from_slice(&out).expect("Pi stdout must be JSON");
assert_eq!(json["decision"], "allow");
assert!(err.is_empty());
}
#[test]
fn render_hook_response_is_none_for_pi() {
let decision = Decision::Deny {
rule_id: "core.x".into(),
reason: "r".into(),
};
assert!(render_hook_response(HookAgent::Pi, &decision).is_none());
}
#[test]
fn copilot_deny_emits_bare_json_with_zero_exit() {
let decision = Decision::Deny {
rule_id: "core.filesystem.destructive-rm".into(),
reason: "blocked".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Copilot, &decision, &mut out, &mut err);
assert_eq!(code, 0, "Copilot deny must exit 0 to stay fail-closed");
let out_s = String::from_utf8_lossy(&out);
assert!(
out_s.contains("\"permissionDecision\":\"deny\""),
"stdout: {out_s}"
);
assert!(String::from_utf8_lossy(&err).contains("blocked"));
}
#[test]
fn copilot_allow_emits_no_stdout_with_zero_exit() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Copilot, &Decision::Allow, &mut out, &mut err);
assert_eq!(code, 0);
assert!(
out.is_empty(),
"Copilot allow must emit no stdout, got: {out:?}"
);
assert!(err.is_empty());
}
#[test]
fn copilot_ask_is_demoted_to_deny() {
let decision = Decision::Ask {
rule_id: "core.test.ask".into(),
reason: "please confirm".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::Copilot, &decision, &mut out, &mut err);
assert_eq!(code, 0, "Copilot stays at exit 0 for every Decision");
let out_s = String::from_utf8_lossy(&out);
assert!(
out_s.contains("\"permissionDecision\":\"deny\""),
"stdout: {out_s}"
);
let err_s = String::from_utf8_lossy(&err);
assert!(err_s.contains("please confirm"), "stderr: {err_s}");
}
#[test]
fn emit_decision_serialises_allow_for_each_hook_agent() {
for agent in [
HookAgent::Copilot,
HookAgent::Cline,
HookAgent::Cursor,
HookAgent::Pi,
HookAgent::ClaudeCode,
HookAgent::Codex,
] {
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(agent, &Decision::Allow, &mut out, &mut err);
assert_eq!(code, 0, "agent {agent:?}");
}
}
#[test]
fn render_hook_response_is_none_for_copilot() {
let decision = Decision::Deny {
rule_id: "core.x".into(),
reason: "r".into(),
};
assert!(render_hook_response(HookAgent::Copilot, &decision).is_none());
}
#[test]
fn emit_decision_serialization_failure_returns_one() {
let decision = Decision::Deny {
rule_id: "core.test.deny".into(),
reason: "blocked".into(),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = emit_decision(HookAgent::ClaudeCode, &decision, &mut out, &mut err);
assert_eq!(code, 2);
assert!(String::from_utf8_lossy(&out).contains("\"permissionDecision\":\"deny\""));
assert!(String::from_utf8_lossy(&err).contains("blocked"));
}
}