ajhahn.de
← eeco
Go 344 lines
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"
}