Skip to main content

kintsugi_intercept/
shim.rs

1//! The `$PATH` shim adapter.
2//!
3//! When a directory of symlinks (`rm`, `git`, `terraform`, …) all pointing at the
4//! `kintsugi-shim` binary is prepended to `$PATH`, every matching shell-out lands
5//! here first. The shim:
6//!
7//! 1. recovers the command name from `argv[0]` (or `argv[1]` if invoked directly),
8//! 2. captures `argv` + cwd into a [`ProposedCommand`] tagged `agent = "shim"`,
9//! 3. asks the daemon for a [`Verdict`] and enforces it, then
10//! 4. on allow, **execs the real binary** so exit code, stdio, and signals are
11//!    forwarded with perfect fidelity (on Unix the shim *becomes* the real
12//!    process).
13//!
14//! Fail-open by default (record-but-don't-block when the daemon is down), which
15//! matches the honest guarantee — "nothing unrecoverable", not "nothing
16//! un-warned". Set `KINTSUGI_FAIL_CLOSED=1` to block instead.
17
18use 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
25/// Exit code used when Kintsugi refuses to run a command (mirrors shell "cannot
26/// execute": 126).
27pub const EXIT_BLOCKED: u8 = 126;
28/// Exit code when the real binary cannot be found (mirrors shell 127).
29pub const EXIT_NOT_FOUND: u8 = 127;
30
31/// Entry point for the `kintsugi-shim` binary.
32///
33/// Returns an [`ExitCode`] only on the non-exec paths (blocked / not-found /
34/// daemon-down-fail-closed). On the happy path under Unix it never returns: the
35/// process image is replaced by the real binary.
36pub 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    // Raw shell-outs have no agent session; group by KINTSUGI_SESSION if the shell
54    // exports one, else leave it unset (best-effort, per CLAUDE.md honesty).
55    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    // Allowed: hand off to the real binary.
64    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
78/// Ask the daemon and translate its verdict into an allow/refuse outcome,
79/// prompting the human with the hold card when the command is held.
80fn consult_daemon(proposed: &ProposedCommand) -> DaemonOutcome {
81    match Client::send(proposed) {
82        Ok(verdict) => enforce(proposed, &verdict),
83        Err(e) => {
84            // Daemon down: locally run the Tier-1 classifier so the catastrophic
85            // hard floor still blocks (fail-closed for catastrophic) even though we
86            // can't record the event. Non-catastrophic honors the fail-open default.
87            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
103/// Map a verdict to an outcome, prompting on Hold.
104fn 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
115/// Show the hold card, read one key from the terminal, and record the human's
116/// resolution. With no terminal/stdin available, default to deny (safe).
117fn 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            // No answer (no TTY, EOF, or anything else) → safe default: deny,
127            // and do not record a resolution (the held event already stands).
128            eprintln!("kintsugi: no decision given; leaving the command held (not run).");
129            return DaemonOutcome::Refuse(EXIT_BLOCKED);
130        }
131    };
132
133    // Record the human's resolution (best-effort).
134    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
149/// Read a single decision key from the controlling terminal, falling back to
150/// stdin. Returns the lowercased first non-whitespace character, if any.
151fn read_key() -> Option<char> {
152    use std::io::BufReader;
153
154    // Prefer the real terminal so we read the human, not an agent's piped stdin.
155    #[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    // Fall back to stdin (used in tests and non-TTY pipelines).
162    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
174/// Whether the shim should block when the daemon is unreachable. True if the
175/// admin-set fail-closed marker is present (the agent can't unset a root-owned
176/// marker) OR the `KINTSUGI_FAIL_CLOSED` env var opts in for personal use. The
177/// marker wins, so an agent can't re-open the gate with `KINTSUGI_FAIL_CLOSED=0`.
178fn 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
186/// Split the program invocation into `(command, args)`.
187///
188/// - Invoked via a symlink (`rm foo`): command = `rm`, args = `[foo]`.
189/// - Invoked directly (`kintsugi-shim rm foo`): command = `rm`, args = `[foo]`.
190fn 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
199/// The basename of a program path, with a trailing `.exe` stripped.
200fn 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
208/// Render a command and args back into a readable string for the log/UI.
209fn 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
224/// The directory containing the running shim executable, canonicalized.
225fn own_dir() -> Option<PathBuf> {
226    let exe = std::env::current_exe().ok()?.canonicalize().ok()?;
227    exe.parent().map(Path::to_path_buf)
228}
229
230/// The canonical path of the running shim executable.
231fn own_exe() -> Option<PathBuf> {
232    std::env::current_exe().ok()?.canonicalize().ok()
233}
234
235/// Resolve the *real* binary for `name` by walking `$PATH`, skipping the shim's
236/// own directory and any entry that resolves back to the shim itself.
237pub fn resolve_real_binary(name: &str) -> Option<PathBuf> {
238    // An explicit path (contains a separator) is used as-is.
239    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        // Skip the shim directory itself.
250        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        // Skip a candidate that resolves back to the shim (e.g. another symlink).
260        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
270/// Whether `path` is a regular, executable file (following symlinks).
271fn 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/// Replace this process with the real binary (Unix) or spawn-and-wait (Windows).
290#[cfg(unix)]
291fn exec_real(real: &Path, argv0: &str, args: &[String]) -> ExitCode {
292    use std::os::unix::process::CommandExt;
293    // Preserve argv[0] (the invoked name) so multi-call binaries — busybox,
294    // gunzip→gzip, etc. — pick the right applet. `exec` only returns on failure;
295    // on success the kernel replaces this image, preserving exit code, stdio, and
296    // signal delivery exactly.
297    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/// Windows has no `exec`; spawn the child, wait, and propagate its exit code.
306#[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        // `sh` exists on every Unix CI image.
398        #[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}