kintsugi_intercept/
hook.rs1use kintsugi_core::{shell, Class, Decision, ProposedCommand};
18use kintsugi_daemon::Client;
19
20pub use crate::dialect::HookOutcome;
21
22use crate::dialect::{self, Dialect, Parsed, Resolved};
23
24pub fn handle_with(dialect: Dialect, input: &str) -> HookOutcome {
26 let parsed = match dialect.parse(input) {
27 Parsed::Shell(s) => s,
28 Parsed::NotShell => return HookOutcome::silent(),
29 Parsed::Bad(e) => {
30 eprintln!(
32 "kintsugi-hook: could not parse {} payload: {e}",
33 dialect.agent_id()
34 );
35 return HookOutcome::silent();
36 }
37 };
38
39 let argv = shell::split(&parsed.command);
40 let proposed = ProposedCommand::new(dialect.agent_id(), parsed.cwd, argv, parsed.command)
41 .with_session(parsed.session_id);
42
43 match Client::send(&proposed) {
44 Ok(verdict) => {
45 let resolved = match dialect::resolve(&verdict) {
46 Resolved::Deny(reason)
52 if verdict.decision == Decision::Hold
53 && verdict.class == Class::Catastrophic =>
54 {
55 Resolved::Deny(held_for_approval(&reason, &proposed.id.to_string()))
56 }
57 other => other,
58 };
59 dialect.format(&resolved)
60 }
61 Err(e) => {
62 if kintsugi_core::classify(&proposed).class == Class::Catastrophic {
66 eprintln!(
67 "kintsugi-hook: daemon unreachable; denying catastrophic (fail-closed): {e}"
68 );
69 dialect.format(&dialect::Resolved::Deny(
70 "Kintsugi daemon unreachable; catastrophic command blocked (fail-closed)"
71 .into(),
72 ))
73 } else if fail_closed() {
74 eprintln!("kintsugi-hook: daemon unreachable; denying (fail-closed): {e}");
75 dialect.format(&dialect::Resolved::Deny(
76 "Kintsugi daemon unreachable (fail-closed)".into(),
77 ))
78 } else {
79 eprintln!("kintsugi-hook: warning: daemon unreachable; allowing unguarded: {e}");
80 dialect.pass()
81 }
82 }
83 }
84}
85
86pub fn handle(input: &str) -> HookOutcome {
88 handle_with(Dialect::Claude, input)
89}
90
91fn held_for_approval(reason: &str, id: &str) -> String {
100 let short = id.get(..8).unwrap_or(id);
101 format!(
102 "{reason} Kintsugi blocked it; the agent will not run it. To run it yourself: \
103 `kintsugi run {short}` — it snapshots the affected files first (so `kintsugi undo` \
104 can roll them back) and confirms with a code typed at your terminal."
105 )
106}
107
108fn fail_closed() -> bool {
112 kintsugi_daemon::is_fail_closed_marked()
113 || matches!(
114 std::env::var("KINTSUGI_FAIL_CLOSED").ok().as_deref(),
115 Some("1") | Some("true") | Some("yes")
116 )
117}
118
119pub fn dialect_from_args<I: IntoIterator<Item = String>>(args: I) -> Dialect {
124 let mut it = args.into_iter();
125 while let Some(a) = it.next() {
126 let value = if let Some(v) = a.strip_prefix("--agent=") {
127 Some(v.to_string())
128 } else if a == "--agent" {
129 it.next()
130 } else {
131 None
132 };
133 if let Some(v) = value {
134 match Dialect::from_agent(&v) {
135 Some(d) => return d,
136 None => {
137 eprintln!("kintsugi-hook: unknown --agent '{v}', defaulting to claude-code");
138 return Dialect::Claude;
139 }
140 }
141 }
142 }
143 Dialect::Claude
144}
145
146pub fn run() -> i32 {
149 let dialect = dialect_from_args(std::env::args().skip(1));
150 let stdin = std::io::stdin();
151 let stdout = std::io::stdout();
152 run_io(dialect, stdin.lock(), stdout.lock())
153}
154
155pub fn run_io<R: std::io::Read, W: std::io::Write>(
157 dialect: Dialect,
158 mut reader: R,
159 mut writer: W,
160) -> i32 {
161 let mut input = String::new();
162 if let Err(e) = reader.read_to_string(&mut input) {
163 eprintln!("kintsugi-hook: failed to read stdin: {e}");
164 return 0; }
166 let outcome = handle_with(dialect, &input);
167 if let Some(out) = outcome.stdout {
168 let _ = writeln!(writer, "{out}");
169 }
170 outcome.exit_code
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn non_shell_tool_is_allowed_silently() {
179 let payload = r#"{"tool_name":"Edit","tool_input":{"file_path":"x"}}"#;
180 assert_eq!(handle(payload), HookOutcome::silent());
181 }
182
183 #[test]
184 fn held_for_approval_points_at_kintsugi_run_with_short_id() {
185 let msg = held_for_approval("recursively deletes files.", "abcd1234-5678-90ab-cdef");
186 assert!(msg.contains("recursively deletes files."));
187 assert!(
188 msg.contains("will not run"),
189 "must say the agent won't run it"
190 );
191 assert!(
192 msg.contains("kintsugi run abcd1234"),
193 "should give the guarded run command"
194 );
195 assert!(msg.contains("undo"), "should mention reversibility");
196 }
197
198 #[test]
199 fn malformed_payload_is_allowed_silently() {
200 assert_eq!(handle("not json"), HookOutcome::silent());
201 }
202
203 #[test]
204 fn empty_command_is_allowed_silently() {
205 let payload = r#"{"tool_name":"Bash","tool_input":{"command":" "}}"#;
206 assert_eq!(handle(payload), HookOutcome::silent());
207 }
208
209 #[test]
210 fn run_io_allows_non_shell_tool_silently() {
211 let input = br#"{"tool_name":"Edit","tool_input":{"file_path":"x"}}"#;
212 let mut out = Vec::new();
213 let code = run_io(Dialect::Claude, &input[..], &mut out);
214 assert_eq!(code, 0);
215 assert!(out.is_empty(), "allow-silent writes nothing");
216 }
217
218 #[test]
219 fn dialect_from_args_reads_flag_forms() {
220 assert_eq!(
221 dialect_from_args(["--agent".to_string(), "cursor".to_string()]),
222 Dialect::Cursor
223 );
224 assert_eq!(
225 dialect_from_args(["--agent=qwen".to_string()]),
226 Dialect::Qwen
227 );
228 assert_eq!(dialect_from_args(Vec::<String>::new()), Dialect::Claude);
230 assert_eq!(
232 dialect_from_args(["--agent=banana".to_string()]),
233 Dialect::Claude
234 );
235 }
236}