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"
}