1use std::ffi::OsStr;
19use std::path::{Path, PathBuf};
20use std::process::ExitCode;
21
22use kintsugi_core::{Decision, ProposedCommand, Verdict};
23use kintsugi_daemon::{Client, Resolution};
24
25pub const EXIT_BLOCKED: u8 = 126;
28pub const EXIT_NOT_FOUND: u8 = 127;
30
31pub fn run() -> ExitCode {
37 let args: Vec<String> = std::env::args().collect();
38 let invoked = program_name(args.first().map(String::as_str).unwrap_or("kintsugi-shim"));
39
40 let (cmd_name, cmd_args) = match split_invocation(&invoked, &args) {
41 Some(v) => v,
42 None => {
43 eprintln!("usage: kintsugi-shim <command> [args...]");
44 return ExitCode::from(EXIT_BLOCKED);
45 }
46 };
47
48 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
49 let raw = render_command(&cmd_name, &cmd_args);
50 let mut argv = Vec::with_capacity(cmd_args.len() + 1);
51 argv.push(cmd_name.clone());
52 argv.extend(cmd_args.iter().cloned());
53 let session = std::env::var("KINTSUGI_SESSION").ok();
56 let proposed = ProposedCommand::new("shim", cwd, argv, raw).with_session(session);
57
58 match consult_daemon(&proposed) {
59 DaemonOutcome::Allow => {}
60 DaemonOutcome::Refuse(code) => return ExitCode::from(code),
61 }
62
63 match resolve_real_binary(&cmd_name) {
65 Some(real) => exec_real(&real, &cmd_name, &cmd_args),
66 None => {
67 eprintln!("kintsugi: {cmd_name}: command not found");
68 ExitCode::from(EXIT_NOT_FOUND)
69 }
70 }
71}
72
73enum DaemonOutcome {
74 Allow,
75 Refuse(u8),
76}
77
78fn consult_daemon(proposed: &ProposedCommand) -> DaemonOutcome {
81 match Client::send(proposed) {
82 Ok(verdict) => enforce(proposed, &verdict),
83 Err(e) => {
84 if kintsugi_core::classify(proposed).class == kintsugi_core::Class::Catastrophic {
88 eprintln!(
89 "kintsugi: daemon unreachable; blocking catastrophic command (fail-closed): {e}"
90 );
91 DaemonOutcome::Refuse(EXIT_BLOCKED)
92 } else if fail_closed() {
93 eprintln!("kintsugi: daemon unreachable; blocking (fail-closed): {e}");
94 DaemonOutcome::Refuse(EXIT_BLOCKED)
95 } else {
96 eprintln!("kintsugi: warning: daemon unreachable; running unguarded: {e}");
97 DaemonOutcome::Allow
98 }
99 }
100 }
101}
102
103fn enforce(proposed: &ProposedCommand, verdict: &Verdict) -> DaemonOutcome {
105 match verdict.decision {
106 Decision::Allow => DaemonOutcome::Allow,
107 Decision::Deny => {
108 eprintln!("kintsugi: blocked [{}]: {}", verdict.class, verdict.reason);
109 DaemonOutcome::Refuse(EXIT_BLOCKED)
110 }
111 Decision::Hold => prompt_and_resolve(proposed, verdict),
112 }
113}
114
115fn prompt_and_resolve(proposed: &ProposedCommand, verdict: &Verdict) -> DaemonOutcome {
118 let color = std::env::var_os("NO_COLOR").is_none();
119 eprint!("{}", crate::holdcard::render(&proposed.raw, verdict, color));
120
121 let (decision, remember) = match read_key() {
122 Some('a') => (Decision::Allow, false),
123 Some('r') => (Decision::Allow, true),
124 Some('d') => (Decision::Deny, false),
125 _ => {
126 eprintln!("kintsugi: no decision given; leaving the command held (not run).");
129 return DaemonOutcome::Refuse(EXIT_BLOCKED);
130 }
131 };
132
133 let resolution = Resolution {
135 command: proposed.clone(),
136 decision,
137 remember,
138 };
139 if let Err(e) = Client::resolve(&resolution) {
140 eprintln!("kintsugi: warning: could not record resolution: {e}");
141 }
142
143 match decision {
144 Decision::Allow => DaemonOutcome::Allow,
145 _ => DaemonOutcome::Refuse(EXIT_BLOCKED),
146 }
147}
148
149fn read_key() -> Option<char> {
152 use std::io::BufReader;
153
154 #[cfg(unix)]
156 if let Ok(tty) = std::fs::File::open("/dev/tty") {
157 if let Some(c) = first_char(BufReader::new(tty)) {
158 return Some(c);
159 }
160 }
161 let stdin = std::io::stdin();
163 first_char(BufReader::new(stdin.lock()))
164}
165
166fn first_char<R: std::io::BufRead>(mut reader: R) -> Option<char> {
167 let mut line = String::new();
168 if reader.read_line(&mut line).ok()? == 0 {
169 return None;
170 }
171 line.trim().chars().next().map(|c| c.to_ascii_lowercase())
172}
173
174fn fail_closed() -> bool {
179 kintsugi_daemon::is_fail_closed_marked()
180 || matches!(
181 std::env::var("KINTSUGI_FAIL_CLOSED").ok().as_deref(),
182 Some("1") | Some("true") | Some("yes")
183 )
184}
185
186fn split_invocation(invoked: &str, args: &[String]) -> Option<(String, Vec<String>)> {
191 if invoked == "kintsugi-shim" || invoked == "kintsugi-shim.exe" {
192 let cmd = args.get(1)?.clone();
193 Some((cmd, args.get(2..).unwrap_or(&[]).to_vec()))
194 } else {
195 Some((invoked.to_string(), args.get(1..).unwrap_or(&[]).to_vec()))
196 }
197}
198
199fn program_name(arg0: &str) -> String {
201 let base = Path::new(arg0)
202 .file_name()
203 .and_then(OsStr::to_str)
204 .unwrap_or(arg0);
205 base.strip_suffix(".exe").unwrap_or(base).to_string()
206}
207
208fn render_command(cmd: &str, args: &[String]) -> String {
210 let mut out = String::from(cmd);
211 for a in args {
212 out.push(' ');
213 if a.is_empty() || a.chars().any(|c| c.is_whitespace() || c == '"') {
214 out.push('"');
215 out.push_str(&a.replace('"', "\\\""));
216 out.push('"');
217 } else {
218 out.push_str(a);
219 }
220 }
221 out
222}
223
224fn own_dir() -> Option<PathBuf> {
226 let exe = std::env::current_exe().ok()?.canonicalize().ok()?;
227 exe.parent().map(Path::to_path_buf)
228}
229
230fn own_exe() -> Option<PathBuf> {
232 std::env::current_exe().ok()?.canonicalize().ok()
233}
234
235pub fn resolve_real_binary(name: &str) -> Option<PathBuf> {
238 if name.contains('/') || (cfg!(windows) && name.contains('\\')) {
240 let p = PathBuf::from(name);
241 return is_executable_file(&p).then_some(p);
242 }
243
244 let own_dir = own_dir();
245 let own_exe = own_exe();
246 let path = std::env::var_os("PATH")?;
247
248 for dir in std::env::split_paths(&path) {
249 if let Some(od) = &own_dir {
251 if dir.canonicalize().ok().as_deref() == Some(od.as_path()) {
252 continue;
253 }
254 }
255 let candidate = dir.join(name);
256 if !is_executable_file(&candidate) {
257 continue;
258 }
259 if let (Ok(cc), Some(oe)) = (candidate.canonicalize(), &own_exe) {
261 if &cc == oe {
262 continue;
263 }
264 }
265 return Some(candidate);
266 }
267 None
268}
269
270fn is_executable_file(path: &Path) -> bool {
272 let Ok(meta) = std::fs::metadata(path) else {
273 return false;
274 };
275 if !meta.is_file() {
276 return false;
277 }
278 #[cfg(unix)]
279 {
280 use std::os::unix::fs::PermissionsExt;
281 meta.permissions().mode() & 0o111 != 0
282 }
283 #[cfg(not(unix))]
284 {
285 true
286 }
287}
288
289#[cfg(unix)]
291fn exec_real(real: &Path, argv0: &str, args: &[String]) -> ExitCode {
292 use std::os::unix::process::CommandExt;
293 let err = std::process::Command::new(real)
298 .arg0(argv0)
299 .args(args)
300 .exec();
301 eprintln!("kintsugi: failed to exec {}: {err}", real.display());
302 ExitCode::from(EXIT_BLOCKED)
303}
304
305#[cfg(not(unix))]
307fn exec_real(real: &Path, _argv0: &str, args: &[String]) -> ExitCode {
308 match std::process::Command::new(real).args(args).status() {
309 Ok(status) => {
310 let code = status.code().unwrap_or(1);
311 ExitCode::from(u8::try_from(code & 0xff).unwrap_or(1))
312 }
313 Err(e) => {
314 eprintln!("kintsugi: failed to run {}: {e}", real.display());
315 ExitCode::from(EXIT_BLOCKED)
316 }
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn program_name_strips_dir_and_exe() {
326 assert_eq!(program_name("/usr/bin/rm"), "rm");
327 assert_eq!(program_name("rm"), "rm");
328 assert_eq!(program_name("git.exe"), "git");
329 #[cfg(windows)]
330 assert_eq!(program_name(r"C:\tools\git.exe"), "git");
331 }
332
333 #[test]
334 fn split_invocation_symlink_form() {
335 let args = vec!["rm".to_string(), "-rf".to_string(), "x".to_string()];
336 let (cmd, rest) = split_invocation("rm", &args).unwrap();
337 assert_eq!(cmd, "rm");
338 assert_eq!(rest, vec!["-rf", "x"]);
339 }
340
341 #[test]
342 fn split_invocation_direct_form() {
343 let args = vec![
344 "kintsugi-shim".to_string(),
345 "git".to_string(),
346 "status".to_string(),
347 ];
348 let (cmd, rest) = split_invocation("kintsugi-shim", &args).unwrap();
349 assert_eq!(cmd, "git");
350 assert_eq!(rest, vec!["status"]);
351 }
352
353 #[test]
354 fn split_invocation_direct_form_requires_a_command() {
355 let args = vec!["kintsugi-shim".to_string()];
356 assert!(split_invocation("kintsugi-shim", &args).is_none());
357 }
358
359 #[test]
360 fn render_command_quotes_whitespace() {
361 assert_eq!(render_command("rm", &["a".into(), "b".into()]), "rm a b");
362 assert_eq!(
363 render_command("git", &["commit".into(), "-m".into(), "two words".into()]),
364 r#"git commit -m "two words""#
365 );
366 }
367
368 #[test]
369 fn render_command_quotes_empty_and_quoted_args() {
370 assert_eq!(render_command("x", &["".into()]), r#"x """#);
371 assert_eq!(render_command("echo", &[r#"a"b"#.into()]), r#"echo "a\"b""#);
372 }
373
374 #[test]
375 fn resolve_explicit_path_is_used_directly() {
376 #[cfg(unix)]
377 {
378 assert_eq!(
379 resolve_real_binary("/bin/sh"),
380 Some(PathBuf::from("/bin/sh"))
381 );
382 assert!(resolve_real_binary("/definitely/not/here").is_none());
383 }
384 }
385
386 #[test]
387 fn first_char_reads_lowercased_first_nonspace() {
388 use std::io::Cursor;
389 assert_eq!(first_char(Cursor::new(b"A\n".to_vec())), Some('a'));
390 assert_eq!(first_char(Cursor::new(b" d ".to_vec())), Some('d'));
391 assert_eq!(first_char(Cursor::new(b"".to_vec())), None);
392 assert_eq!(first_char(Cursor::new(b"\n".to_vec())), None);
393 }
394
395 #[test]
396 fn resolve_finds_a_real_binary_on_path() {
397 #[cfg(unix)]
399 {
400 let found = resolve_real_binary("sh");
401 assert!(found.is_some(), "expected to find sh on PATH");
402 assert!(is_executable_file(&found.unwrap()));
403 }
404 }
405
406 #[test]
407 fn resolve_missing_binary_is_none() {
408 assert!(resolve_real_binary("definitely-not-a-real-binary-xyz").is_none());
409 }
410}