cc-audit 3.6.0

Security auditor for Claude Code skills, hooks, and MCP servers
Documentation
use std::fs;
use std::path::Path;

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use thiserror::Error;

const PRE_COMMIT_SCRIPT: &str = r#"#!/bin/sh
# cc-audit pre-commit hook
# Automatically generated by cc-audit --init-hook

# Scan staged skill files for security issues
# You can customize the paths and options below

# Find all staged .md files and skill directories
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)

# Check if there are any skill-related files
SKILL_FILES=$(echo "$STAGED_FILES" | grep -E '(SKILL\.md|CLAUDE\.md|\.claude/|mcp\.json)')

if [ -n "$SKILL_FILES" ]; then
    echo "cc-audit: Checking staged skill files..."

    # Run cc-audit on the repository root
    if ! cc-audit .; then
        echo ""
        echo "cc-audit: Security issues found!"
        echo "Please fix the issues above before committing."
        echo ""
        echo "To bypass this check, use: git commit --no-verify"
        exit 1
    fi

    echo "cc-audit: All checks passed!"
fi

exit 0
"#;

pub struct HookInstaller;

impl HookInstaller {
    pub fn install(repo_path: &Path) -> Result<(), HookError> {
        let git_dir = repo_path.join(".git");
        if !git_dir.exists() {
            return Err(HookError::NotAGitRepository);
        }

        let hooks_dir = git_dir.join("hooks");
        if !hooks_dir.exists() {
            fs::create_dir_all(&hooks_dir).map_err(HookError::CreateDir)?;
        }

        let pre_commit_path = hooks_dir.join("pre-commit");

        // Check if a pre-commit hook already exists
        if pre_commit_path.exists() {
            let existing = fs::read_to_string(&pre_commit_path).map_err(HookError::ReadFile)?;

            if existing.contains("cc-audit") {
                return Err(HookError::AlreadyInstalled);
            }

            return Err(HookError::ExistingHook);
        }

        // Write the pre-commit script
        fs::write(&pre_commit_path, PRE_COMMIT_SCRIPT).map_err(HookError::WriteFile)?;

        // Make it executable (Unix only)
        #[cfg(unix)]
        {
            let mut perms = fs::metadata(&pre_commit_path)
                .map_err(HookError::SetPermissions)?
                .permissions();
            perms.set_mode(0o755);
            fs::set_permissions(&pre_commit_path, perms).map_err(HookError::SetPermissions)?;
        }

        Ok(())
    }

    pub fn uninstall(repo_path: &Path) -> Result<(), HookError> {
        let git_dir = repo_path.join(".git");
        if !git_dir.exists() {
            return Err(HookError::NotAGitRepository);
        }

        let pre_commit_path = git_dir.join("hooks").join("pre-commit");

        if !pre_commit_path.exists() {
            return Err(HookError::NotInstalled);
        }

        let existing = fs::read_to_string(&pre_commit_path).map_err(HookError::ReadFile)?;

        if !existing.contains("cc-audit") {
            return Err(HookError::NotOurHook);
        }

        fs::remove_file(&pre_commit_path).map_err(HookError::RemoveFile)?;

        Ok(())
    }
}

#[derive(Debug, Error)]
pub enum HookError {
    #[error("Not a git repository")]
    NotAGitRepository,

    #[error("cc-audit pre-commit hook is already installed")]
    AlreadyInstalled,

    #[error("A pre-commit hook already exists. Remove it first or add cc-audit manually")]
    ExistingHook,

    #[error("No pre-commit hook is installed")]
    NotInstalled,

    #[error("The existing pre-commit hook was not installed by cc-audit")]
    NotOurHook,

    #[error("Failed to create hooks directory: {0}")]
    CreateDir(#[source] std::io::Error),

    #[error("Failed to read hook file: {0}")]
    ReadFile(#[source] std::io::Error),

    #[error("Failed to write hook file: {0}")]
    WriteFile(#[source] std::io::Error),

    #[error("Failed to set permissions: {0}")]
    SetPermissions(#[source] std::io::Error),

    #[error("Failed to remove hook file: {0}")]
    RemoveFile(#[source] std::io::Error),
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn create_git_repo() -> TempDir {
        let temp_dir = TempDir::new().unwrap();
        let git_dir = temp_dir.path().join(".git");
        fs::create_dir(&git_dir).unwrap();
        temp_dir
    }

    #[test]
    fn test_install_in_git_repo() {
        let repo = create_git_repo();
        let result = HookInstaller::install(repo.path());
        assert!(result.is_ok());

        // Verify the hook file exists
        let hook_path = repo.path().join(".git/hooks/pre-commit");
        assert!(hook_path.exists());

        // Verify content
        let content = fs::read_to_string(&hook_path).unwrap();
        assert!(content.contains("cc-audit"));
    }

    #[test]
    fn test_install_not_a_git_repo() {
        let temp_dir = TempDir::new().unwrap();
        let result = HookInstaller::install(temp_dir.path());
        assert!(matches!(result, Err(HookError::NotAGitRepository)));
    }

    #[test]
    fn test_install_already_installed() {
        let repo = create_git_repo();

        // First install
        HookInstaller::install(repo.path()).unwrap();

        // Second install should fail
        let result = HookInstaller::install(repo.path());
        assert!(matches!(result, Err(HookError::AlreadyInstalled)));
    }

    #[test]
    fn test_install_existing_hook() {
        let repo = create_git_repo();

        // Create hooks dir and an existing hook
        let hooks_dir = repo.path().join(".git/hooks");
        fs::create_dir(&hooks_dir).unwrap();
        fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\necho 'other hook'").unwrap();

        let result = HookInstaller::install(repo.path());
        assert!(matches!(result, Err(HookError::ExistingHook)));
    }

    #[test]
    fn test_uninstall() {
        let repo = create_git_repo();

        // Install first
        HookInstaller::install(repo.path()).unwrap();

        // Uninstall
        let result = HookInstaller::uninstall(repo.path());
        assert!(result.is_ok());

        // Verify hook is removed
        let hook_path = repo.path().join(".git/hooks/pre-commit");
        assert!(!hook_path.exists());
    }

    #[test]
    fn test_uninstall_not_installed() {
        let repo = create_git_repo();
        let result = HookInstaller::uninstall(repo.path());
        assert!(matches!(result, Err(HookError::NotInstalled)));
    }

    #[test]
    fn test_uninstall_not_a_git_repo() {
        let temp_dir = TempDir::new().unwrap();
        let result = HookInstaller::uninstall(temp_dir.path());
        assert!(matches!(result, Err(HookError::NotAGitRepository)));
    }

    #[test]
    fn test_uninstall_not_our_hook() {
        let repo = create_git_repo();

        // Create hooks dir and a different hook
        let hooks_dir = repo.path().join(".git/hooks");
        fs::create_dir(&hooks_dir).unwrap();
        fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\necho 'other hook'").unwrap();

        let result = HookInstaller::uninstall(repo.path());
        assert!(matches!(result, Err(HookError::NotOurHook)));
    }

    #[test]
    #[cfg(unix)]
    fn test_hook_is_executable() {
        let repo = create_git_repo();
        HookInstaller::install(repo.path()).unwrap();

        let hook_path = repo.path().join(".git/hooks/pre-commit");
        let metadata = fs::metadata(&hook_path).unwrap();
        let permissions = metadata.permissions();

        // Check if executable bit is set (0o755 = rwxr-xr-x)
        assert_eq!(permissions.mode() & 0o111, 0o111);
    }

    #[test]
    fn test_hook_error_display_not_a_git_repository() {
        let error = HookError::NotAGitRepository;
        assert_eq!(format!("{}", error), "Not a git repository");
    }

    #[test]
    fn test_hook_error_display_already_installed() {
        let error = HookError::AlreadyInstalled;
        assert_eq!(
            format!("{}", error),
            "cc-audit pre-commit hook is already installed"
        );
    }

    #[test]
    fn test_hook_error_display_existing_hook() {
        let error = HookError::ExistingHook;
        assert_eq!(
            format!("{}", error),
            "A pre-commit hook already exists. Remove it first or add cc-audit manually"
        );
    }

    #[test]
    fn test_hook_error_display_not_installed() {
        let error = HookError::NotInstalled;
        assert_eq!(format!("{}", error), "No pre-commit hook is installed");
    }

    #[test]
    fn test_hook_error_display_not_our_hook() {
        let error = HookError::NotOurHook;
        assert_eq!(
            format!("{}", error),
            "The existing pre-commit hook was not installed by cc-audit"
        );
    }

    #[test]
    fn test_hook_error_display_create_dir() {
        let io_error =
            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
        let error = HookError::CreateDir(io_error);
        assert!(format!("{}", error).starts_with("Failed to create hooks directory:"));
    }

    #[test]
    fn test_hook_error_display_read_file() {
        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
        let error = HookError::ReadFile(io_error);
        assert!(format!("{}", error).starts_with("Failed to read hook file:"));
    }

    #[test]
    fn test_hook_error_display_write_file() {
        let io_error = std::io::Error::other("disk full");
        let error = HookError::WriteFile(io_error);
        assert!(format!("{}", error).starts_with("Failed to write hook file:"));
    }

    #[test]
    fn test_hook_error_display_set_permissions() {
        let io_error = std::io::Error::new(
            std::io::ErrorKind::PermissionDenied,
            "operation not permitted",
        );
        let error = HookError::SetPermissions(io_error);
        assert!(format!("{}", error).starts_with("Failed to set permissions:"));
    }

    #[test]
    fn test_hook_error_display_remove_file() {
        let io_error = std::io::Error::other("file in use");
        let error = HookError::RemoveFile(io_error);
        assert!(format!("{}", error).starts_with("Failed to remove hook file:"));
    }

    #[test]
    fn test_hook_error_is_error() {
        // Verify HookError implements std::error::Error
        let error: Box<dyn std::error::Error> = Box::new(HookError::NotAGitRepository);
        assert!(!error.to_string().is_empty());
    }
}