package hooks import ( "encoding/json" "fmt" "strings" "time" "github.com/ajhahnde/eeco/internal/config" ) // The commit-guard is eeco's harness-layer enforcement: a Claude Code // PreToolUse hook that runs eeco's attribution detector against a pending // `git commit` and denies it before it executes — in any repo, even one // eeco does not manage, and not bypassable by `git commit --no-verify` // (the hook sits above git). It installs a PreToolUse group into the same // JSON settings file the session-start channel edits (the AI CLI's // settings.json), reusing the same atomic-write / backup / validate / // restore machinery as the session-start JSON path. The installed command // invokes the hidden `eeco hooks commit-guard-check` runner. // // Default OFF, opt-in, reversible: the operator enables it deliberately // (e.g. in a foreign repo driven through the harness), `off` removes only // eeco's group, and foreign PreToolUse groups and unknown keys are // preserved exactly. // commitGuardCommand is the command string written into the PreToolUse // group. It carries the namespace token so removal is exact and // path-independent, the analog of sessionCommand(). func commitGuardCommand() string { return fmt.Sprintf("%q %s", selfPath(), commitGuardToken) } // preToolGroup is the PreToolUse group eeco appends. Unlike a SessionStart // group it carries a "matcher" (Bash), since the guard only inspects Bash // tool calls; the runner then self-filters to a real `git commit`. func preToolGroup() map[string]any { return map[string]any{ "matcher": "Bash", "hooks": []any{ map[string]any{ "type": "command", "command": commitGuardCommand(), }, }, } } // preToolGroups returns the PreToolUse group list, or nil. func preToolGroups(root map[string]any) []any { h, ok := root["hooks"].(map[string]any) if !ok { return nil } groups, _ := h["PreToolUse"].([]any) return groups } // groupHasCommitGuardToken reports whether a PreToolUse group carries a // hook command containing eeco's commit-guard namespace token. func groupHasCommitGuardToken(group any) bool { gm, ok := group.(map[string]any) if !ok { return false } hs, ok := gm["hooks"].([]any) if !ok { return false } for _, h := range hs { hm, ok := h.(map[string]any) if !ok { continue } if cmd, ok := hm["command"].(string); ok && strings.Contains(cmd, commitGuardToken) { return true } } return false } // commitGuardInstalled reports whether root already contains eeco's // commit-guard PreToolUse group (identified by the namespace token). func commitGuardInstalled(root map[string]any) bool { for _, g := range preToolGroups(root) { if groupHasCommitGuardToken(g) { return true } } return false } func addPreToolGroup(root map[string]any) { h, ok := root["hooks"].(map[string]any) if !ok { h = map[string]any{} root["hooks"] = h } groups, _ := h["PreToolUse"].([]any) h["PreToolUse"] = append(groups, preToolGroup()) } func removePreToolGroups(root map[string]any) { h, ok := root["hooks"].(map[string]any) if !ok { return } groups, ok := h["PreToolUse"].([]any) if !ok { return } kept := make([]any, 0, len(groups)) for _, g := range groups { if groupHasCommitGuardToken(g) { continue } kept = append(kept, g) } if len(kept) == 0 { // Leave no empty PreToolUse array behind: drop the key, and the // hooks object too if eeco's edit left it empty. delete(h, "PreToolUse") if len(h) == 0 { delete(root, "hooks") } return } h["PreToolUse"] = kept } // rewriteCommitGuardCommand walks every PreToolUse group and replaces any // command containing eeco's token whose value differs from want. Returns // true if any command was changed. func rewriteCommitGuardCommand(root map[string]any, want string) bool { changed := false for _, g := range preToolGroups(root) { gm, ok := g.(map[string]any) if !ok { continue } hs, ok := gm["hooks"].([]any) if !ok { continue } for _, h := range hs { hm, ok := h.(map[string]any) if !ok { continue } cmd, ok := hm["command"].(string) if !ok || !strings.Contains(cmd, commitGuardToken) || cmd == want { continue } hm["command"] = want changed = true } } return changed } // EnableCommitGuard installs the commit-guard PreToolUse group into the // JSON settings file. It is a no-op when already installed, refuses (and // touches nothing) when the settings file is present but not valid JSON, // and restores the original on a post-edit validation failure. Returns // ErrCommitGuardNotConfigured when no settings file is configured. func EnableCommitGuard(cfg *config.Config) (string, error) { if cfg.SessionSettingsPath == "" { return "", ErrCommitGuardNotConfigured } path := cfg.SessionSettingsPath orig, existed, perm, rerr := readSettings(path) if rerr != nil { return "", rerr } root := map[string]any{} if existed { if jerr := json.Unmarshal(orig, &root); jerr != nil { return "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path) } } if commitGuardInstalled(root) { return "commit-guard already enabled (" + path + ")", nil } backup, berr := backupOriginal(cfg, orig, existed) if berr != nil { return "", berr } addPreToolGroup(root) if werr := writeJSONAtomic(path, root, perm); werr != nil { return "", werr } if verr := validateJSON(path); verr != nil { _ = restoreOriginal(path, orig, existed) return "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr) } l, lerr := loadLedger(cfg) if lerr != nil { return "", lerr } l.CommitGuard = record{ Installed: true, Path: path, Backup: backup, At: time.Now().UTC().Format(time.RFC3339), } if err := saveLedger(cfg, l); err != nil { return "", err } msg := "commit-guard enabled (" + path if backup != "" { msg += ", backup " + backup } return msg + ")", nil } // DisableCommitGuard removes eeco's commit-guard PreToolUse group, // preserving foreign PreToolUse groups and unknown keys. It is a no-op // when not installed, and restores the original on a post-edit validation // failure. func DisableCommitGuard(cfg *config.Config) (string, error) { if cfg.SessionSettingsPath == "" { return "", ErrCommitGuardNotConfigured } path := cfg.SessionSettingsPath l, lerr := loadLedger(cfg) if lerr != nil { return "", lerr } orig, existed, perm, rerr := readSettings(path) if rerr != nil { return "", rerr } notEnabled := func() (string, error) { l.CommitGuard = record{} if err := saveLedger(cfg, l); err != nil { return "", err } return "commit-guard not enabled", nil } if !existed { return notEnabled() } root := map[string]any{} if jerr := json.Unmarshal(orig, &root); jerr != nil { return "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path) } if !commitGuardInstalled(root) { return notEnabled() } backup, berr := backupOriginal(cfg, orig, existed) if berr != nil { return "", berr } removePreToolGroups(root) if werr := writeJSONAtomic(path, root, perm); werr != nil { return "", werr } if verr := validateJSON(path); verr != nil { _ = restoreOriginal(path, orig, existed) return "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr) } l.CommitGuard = record{} if err := saveLedger(cfg, l); err != nil { return "", err } msg := "commit-guard disabled (" + path if backup != "" { msg += ", backup " + backup } return msg + ")", nil } // RefreshCommitGuard rewrites the eeco commit-guard command in the // settings file when its embedded binary path no longer matches what // selfPath() resolves today — the self-heal for a `brew upgrade eeco` // that moved the cellar directory. No-op when the guard is not installed // or the command is already current. func RefreshCommitGuard(cfg *config.Config) (string, error) { if cfg.SessionSettingsPath == "" { return "", ErrCommitGuardNotConfigured } path := cfg.SessionSettingsPath orig, existed, perm, rerr := readSettings(path) if rerr != nil { return "", rerr } if !existed { return "commit-guard not enabled", nil } root := map[string]any{} if jerr := json.Unmarshal(orig, &root); jerr != nil { return "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path) } if !commitGuardInstalled(root) { return "commit-guard not enabled", nil } if !rewriteCommitGuardCommand(root, commitGuardCommand()) { return "commit-guard already current", nil } if _, berr := backupOriginal(cfg, orig, existed); berr != nil { return "", berr } if werr := writeJSONAtomic(path, root, perm); werr != nil { return "", werr } if verr := validateJSON(path); verr != nil { _ = restoreOriginal(path, orig, existed) return "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr) } l, lerr := loadLedger(cfg) if lerr != nil { return "", lerr } l.CommitGuard.Installed = true l.CommitGuard.Path = path l.CommitGuard.At = time.Now().UTC().Format(time.RFC3339) if err := saveLedger(cfg, l); err != nil { return "", err } return "commit-guard refreshed (" + path + ")", nil } // commitGuardStatus reports on/off for the commit-guard hook, reflecting // on-disk reality so a hand-removed group reads as off. It changes // nothing. "not configured" when no settings file is set. func commitGuardStatus(cfg *config.Config) string { if cfg.SessionSettingsPath == "" { return "not configured" } orig, existed, _, err := readSettings(cfg.SessionSettingsPath) if err != nil || !existed { return "off" } root := map[string]any{} if json.Unmarshal(orig, &root) != nil { return "unknown (settings file is not valid JSON)" } if commitGuardInstalled(root) { return "on" } return "off" }