UNPKG

ailock

Version:

AI-Proof File Guard - Protect sensitive files from accidental AI modifications

241 lines (233 loc) 7.53 kB
import { simpleGit, CheckRepoActions } from 'simple-git'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { mkdir } from 'fs/promises'; import path from 'path'; import { loadConfig, findProtectedFiles } from './config.js'; import { getPlatformAdapter } from './platform.js'; import { gitRepoCache } from './git-cache.js'; /** * Get SimpleGit instance for the current directory */ export function getGit(cwd) { return simpleGit(cwd || process.cwd()); } /** * Check if current directory is a Git repository */ export async function isGitRepository(cwd) { try { const git = getGit(cwd); await git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT); return true; } catch { return false; } } /** * Get the Git repository root directory */ export async function getRepoRoot(cwd) { const checkPath = cwd || process.cwd(); // Check cache first const cached = gitRepoCache.get(checkPath); if (cached !== undefined) { return cached; } // Perform actual Git detection try { const git = getGit(cwd); const repoRoot = await git.revparse(['--show-toplevel']); const result = repoRoot.trim(); // Cache the result gitRepoCache.set(checkPath, result); return result; } catch { // Cache negative result too gitRepoCache.set(checkPath, null); return null; } } /** * Check if a file has staged changes that would be included in commit */ export async function hasStagedChanges(files, cwd) { try { const git = getGit(cwd); const status = await git.status(); const changedFiles = []; const allChangedFiles = [ ...status.staged, ...status.modified, ...status.created, ...status.renamed.map(r => r.to || r.from) ]; for (const file of files) { const relativePath = path.relative(process.cwd(), file); if (allChangedFiles.includes(relativePath)) { changedFiles.push(file); } } return changedFiles; } catch { return []; } } /** * Get information about the pre-commit hook */ export function getHookInfo(repoRoot) { const hookPath = path.join(repoRoot, '.git', 'hooks', 'pre-commit'); const exists = existsSync(hookPath); let isAilockManaged = false; let content; if (exists) { content = readFileSync(hookPath, 'utf-8'); isAilockManaged = content.includes('# ailock-managed') || content.includes('ailock-pre-commit-check'); } return { hookPath, exists, isAilockManaged, content }; } /** * Get comprehensive repository status */ export async function getRepoStatus(cwd) { const workingDir = cwd || process.cwd(); const isRepo = await isGitRepository(workingDir); if (!isRepo) { return { isGitRepo: false, hasAilockHook: false, protectedFiles: [], lockedFiles: [] }; } const repoRoot = await getRepoRoot(workingDir); if (!repoRoot) { throw new Error('Could not determine Git repository root'); } const hookInfo = getHookInfo(repoRoot); // Get files from config AND individually tracked files const config = await loadConfig(workingDir); const configFiles = await findProtectedFiles(config); // Import directory tracker to get individually locked files const { getTrackedLockedFiles, getProtectedProjectsList } = await import('./directory-tracker.js'); const trackedFiles = await getTrackedLockedFiles(); // Auto-detect and register current project if we have .ailock but no projects // This provides fallback for migration issues const projects = await getProtectedProjectsList(); if (projects.length === 0 && configFiles.length > 0) { // We have protected files from config but no projects tracked if (process.env.AILOCK_DEBUG === 'true') { console.log(`Debug: Auto-detecting project at ${workingDir} with ${configFiles.length} protected files`); } } // Combine and deduplicate const allProtectedFiles = [...new Set([...configFiles, ...trackedFiles])]; // Check which files are currently locked const adapter = getPlatformAdapter(); const lockedFiles = []; for (const file of allProtectedFiles) { try { const isLocked = await adapter.isLocked(file); if (isLocked) { lockedFiles.push(file); } } catch { // Ignore errors for individual files } } return { isGitRepo: true, hasAilockHook: hookInfo.isAilockManaged, hookInfo, protectedFiles: allProtectedFiles, lockedFiles }; } /** * Generate pre-commit hook script content * Uses null-terminated file processing to prevent shell injection */ export function generatePreCommitHook() { return `#!/bin/sh # ailock-managed # This hook is managed by ailock - do not edit manually # Generated on ${new Date().toISOString()} # ailock pre-commit protection # Prevents committing changes to locked files set -e # Check if ailock is available if ! command -v ailock >/dev/null 2>&1; then echo "Warning: ailock not found in PATH, skipping locked file check" exit 0 fi # Check if there are any staged files if [ -z "$(git diff --cached --name-only)" ]; then exit 0 fi echo "🔒 Checking for modifications to locked files..." # Process files safely using null delimiter to handle special characters in filenames # This prevents shell injection attacks from malicious filenames check_failed=0 git diff --cached --name-only -z | while IFS= read -r -d '' filename; do if [ -n "$filename" ]; then # Pass each filename as a properly quoted argument if ! ailock pre-commit-check "$filename"; then check_failed=1 break fi fi done || check_failed=1 if [ $check_failed -eq 1 ]; then echo "" echo "❌ Commit blocked: Attempted to modify locked files" echo "💡 To edit these files:" echo " 1. Run: ailock unlock <filename>" echo " 2. Make your changes" echo " 3. Run: ailock lock <filename>" echo " 4. Commit your changes" exit 1 fi echo "✅ No locked files modified" exit 0 `; } /** * Install or update the pre-commit hook */ export async function installPreCommitHook(repoRoot, force = false) { const hookInfo = getHookInfo(repoRoot); if (hookInfo.exists && !hookInfo.isAilockManaged && !force) { throw new Error('Pre-commit hook already exists and is not managed by ailock. Use --force to overwrite.'); } // Ensure hooks directory exists const hooksDir = path.dirname(hookInfo.hookPath); await mkdir(hooksDir, { recursive: true }); // Write the hook const hookContent = generatePreCommitHook(); writeFileSync(hookInfo.hookPath, hookContent, { mode: 0o755 }); } /** * Remove ailock pre-commit hook */ export async function removePreCommitHook(repoRoot) { const hookInfo = getHookInfo(repoRoot); if (!hookInfo.exists) { throw new Error('No pre-commit hook found'); } if (!hookInfo.isAilockManaged) { throw new Error('Pre-commit hook is not managed by ailock'); } // Remove the hook file const { unlinkSync } = await import('fs'); unlinkSync(hookInfo.hookPath); } //# sourceMappingURL=git.js.map