vetto 0.2.8

Daemon-less sandbox + security layer for AI coding agents (Landlock/Seatbelt, TUI statusline, post-session audit reports)
Documentation
//! Transparent Git auto-wrapping via `core.hooksPath` (Step 16).
//!
//! Intercepts Git lifecycle hooks (pre-commit, pre-push, pre-rebase, etc.)
//! and executes them inside an isolated Vetto sandbox.

use anyhow::{bail, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

/// Standard Git hooks intercepted by Vetto.
pub const GIT_HOOK_NAMES: &[&str] = &[
    "pre-commit",
    "pre-push",
    "pre-rebase",
    "post-checkout",
    "commit-msg",
    "prepare-commit-msg",
    "post-merge",
    "post-rewrite",
];

/// Status of Git hook auto-wrapping.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GitHookStatus {
    pub is_configured: bool,
    pub global: bool,
    pub hooks_dir: PathBuf,
    pub configured_hooks_path: Option<String>,
    pub active_hooks: Vec<String>,
}

/// Resolves the Git hooks directory path.
pub fn get_git_hooks_dir(global: bool, base_dir: Option<&Path>) -> Result<PathBuf> {
    if global {
        let home = std::env::var_os("HOME")
            .or_else(|| std::env::var_os("USERPROFILE"))
            .map(PathBuf::from)
            .context("neither HOME nor USERPROFILE is set")?;
        Ok(home.join(".vetto").join("git-hooks"))
    } else {
        let base = match base_dir {
            Some(b) => b.to_path_buf(),
            None => std::env::current_dir().context("getcwd")?,
        };
        Ok(base.join(".vetto").join("git-hooks"))
    }
}

/// Generates a POSIX shell hook script for the specified hook event.
pub fn generate_git_hook_script(hook_name: &str) -> String {
    format!(
        r#"#!/bin/sh
# Vetto transparent Git hook wrapper for: {hook_name}
# Automatically generated by `vetto hook install --git`. Do not edit.

HOOK_NAME="{hook_name}"

# If already running inside Vetto sandbox, bypass to avoid recursion
if [ -n "$VETTO_SANDBOXED" ] || [ -n "$VETTO_SHIM_ACTIVE" ]; then
    # If a repository-local hook was chained or backed up, execute it directly
    if [ -n "$VETTO_CHAINED_HOOKS_DIR" ] && [ -x "$VETTO_CHAINED_HOOKS_DIR/$HOOK_NAME" ]; then
        exec "$VETTO_CHAINED_HOOKS_DIR/$HOOK_NAME" "$@"
    fi
    exit 0
fi

# Locate repository root and local hook if present
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
LOCAL_HOOK=""
if [ -n "$REPO_ROOT" ] && [ -f "$REPO_ROOT/.git/hooks/$HOOK_NAME" ] && [ -x "$REPO_ROOT/.git/hooks/$HOOK_NAME" ]; then
    LOCAL_HOOK="$REPO_ROOT/.git/hooks/$HOOK_NAME"
fi

export VETTO_SANDBOXED=1
export VETTO_SHIM_ACTIVE=1

if [ -n "$LOCAL_HOOK" ]; then
    if command -v vetto >/dev/null 2>&1; then
        exec vetto -- "$LOCAL_HOOK" "$@"
    else
        exec "$LOCAL_HOOK" "$@"
    fi
else
    # Default: allow hook to succeed cleanly
    exit 0
fi
"#
    )
}

/// Installs Git hooks and configures Git `core.hooksPath`.
pub fn install_git_hooks(global: bool, base_dir: Option<&Path>, _force: bool) -> Result<PathBuf> {
    let hooks_dir = get_git_hooks_dir(global, base_dir)?;
    fs::create_dir_all(&hooks_dir).with_context(|| {
        format!(
            "failed to create git hooks directory {}",
            hooks_dir.display()
        )
    })?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = fs::metadata(&hooks_dir)?.permissions();
        perms.set_mode(0o755);
        let _ = fs::set_permissions(&hooks_dir, perms);
    }

    for &hook_name in GIT_HOOK_NAMES {
        let hook_file = hooks_dir.join(hook_name);
        let script = generate_git_hook_script(hook_name);
        fs::write(&hook_file, script)
            .with_context(|| format!("failed to write hook file {}", hook_file.display()))?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = fs::metadata(&hook_file)?.permissions();
            perms.set_mode(0o755);
            fs::set_permissions(&hook_file, perms).with_context(|| {
                format!("failed to make hook executable: {}", hook_file.display())
            })?;
        }
    }

    // Configure git core.hooksPath
    let mut git_cmd = Command::new("git");
    git_cmd.arg("config");
    if global {
        git_cmd.arg("--global");
    } else {
        git_cmd.arg("--local");
    }
    git_cmd.arg("core.hooksPath");
    git_cmd.arg(hooks_dir.to_string_lossy().as_ref());

    if let Some(b) = base_dir {
        if !global {
            git_cmd.current_dir(b);
        }
    }

    let output = git_cmd.output();
    match output {
        Ok(out) if out.status.success() => Ok(hooks_dir),
        Ok(out) => {
            let stderr = String::from_utf8_lossy(&out.stderr);
            bail!("git config core.hooksPath failed: {stderr}");
        }
        Err(e) => {
            // If git binary is not installed, we still created the hook files
            tracing::warn!("could not invoke git to set core.hooksPath: {e}");
            Ok(hooks_dir)
        }
    }
}

/// Uninstalls Git hooks and unsets `core.hooksPath` if it points to Vetto's directory.
pub fn uninstall_git_hooks(global: bool, base_dir: Option<&Path>) -> Result<bool> {
    let hooks_dir = get_git_hooks_dir(global, base_dir)?;

    let mut get_cmd = Command::new("git");
    get_cmd.arg("config");
    if global {
        get_cmd.arg("--global");
    } else {
        get_cmd.arg("--local");
    }
    get_cmd.arg("core.hooksPath");
    if let Some(b) = base_dir {
        if !global {
            get_cmd.current_dir(b);
        }
    }

    let configured = if let Ok(out) = get_cmd.output() {
        if out.status.success() {
            let val = String::from_utf8_lossy(&out.stdout).trim().to_string();
            Some(val)
        } else {
            None
        }
    } else {
        None
    };

    let should_unset = if let Some(ref path_str) = configured {
        Path::new(path_str) == hooks_dir || path_str.contains(".vetto/git-hooks")
    } else {
        false
    };

    if should_unset {
        let mut unset_cmd = Command::new("git");
        unset_cmd.arg("config");
        if global {
            unset_cmd.arg("--global");
        } else {
            unset_cmd.arg("--local");
        }
        unset_cmd.args(["--unset", "core.hooksPath"]);
        if let Some(b) = base_dir {
            if !global {
                unset_cmd.current_dir(b);
            }
        }
        let _ = unset_cmd.output();
    }

    if hooks_dir.exists() {
        let _ = fs::remove_dir_all(&hooks_dir);
    }

    Ok(should_unset)
}

/// Queries the status of Git hook configuration.
pub fn git_hooks_status(global: bool, base_dir: Option<&Path>) -> Result<GitHookStatus> {
    let hooks_dir = get_git_hooks_dir(global, base_dir)?;

    let mut get_cmd = Command::new("git");
    get_cmd.arg("config");
    if global {
        get_cmd.arg("--global");
    } else {
        get_cmd.arg("--local");
    }
    get_cmd.arg("core.hooksPath");
    if let Some(b) = base_dir {
        if !global {
            get_cmd.current_dir(b);
        }
    }

    let configured_hooks_path = if let Ok(out) = get_cmd.output() {
        if out.status.success() {
            let val = String::from_utf8_lossy(&out.stdout).trim().to_string();
            if val.is_empty() {
                None
            } else {
                Some(val)
            }
        } else {
            None
        }
    } else {
        None
    };

    let is_configured = if let Some(ref path_str) = configured_hooks_path {
        Path::new(path_str) == hooks_dir || path_str.contains(".vetto/git-hooks")
    } else {
        false
    };

    let mut active_hooks = Vec::new();
    if hooks_dir.exists() {
        if let Ok(entries) = fs::read_dir(&hooks_dir) {
            for entry in entries.flatten() {
                if let Ok(file_type) = entry.file_type() {
                    if file_type.is_file() || file_type.is_symlink() {
                        active_hooks.push(entry.file_name().to_string_lossy().to_string());
                    }
                }
            }
        }
    }
    active_hooks.sort();

    Ok(GitHookStatus {
        is_configured,
        global,
        hooks_dir,
        configured_hooks_path,
        active_hooks,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_test_dir(name: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "vetto-git-hooks-{name}-{}",
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn generates_valid_hook_script() {
        let script = generate_git_hook_script("pre-commit");
        assert!(script.contains("#!/bin/sh"));
        assert!(script.contains("HOOK_NAME=\"pre-commit\""));
        assert!(script.contains("VETTO_SANDBOXED"));
        assert!(script.contains("vetto -- \"$LOCAL_HOOK\""));
    }

    #[test]
    fn gets_local_hooks_dir_correctly() {
        let temp = temp_test_dir("local-path");
        let path = get_git_hooks_dir(false, Some(&temp)).unwrap();
        assert_eq!(path, temp.join(".vetto").join("git-hooks"));
        let _ = fs::remove_dir_all(&temp);
    }
}