diff --git a/internal/config/config.go b/internal/config/config.go index fc9682c..aae6e5d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,29 @@ type Config struct { RateLimits RateLimitSection `toml:"rate_limits"` Security SecuritySection `toml:"security"` Session SessionSection `toml:"session"` + Hooks []HookConfig `toml:"hooks"` +} + +// HookConfig is a single hook entry from TOML config. +// +// Example: +// +// [[hooks]] +// name = "block-dangerous-bash" +// event = "pre_tool_use" +// type = "command" +// exec = "bash-safety-check.sh" +// tool_pattern = "bash*" +// timeout = "10s" +// fail_open = false +type HookConfig struct { + Name string `toml:"name"` + Event string `toml:"event"` + Type string `toml:"type"` + Exec string `toml:"exec"` + Timeout string `toml:"timeout"` + FailOpen bool `toml:"fail_open"` + ToolPattern string `toml:"tool_pattern"` } type SessionSection struct { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0a0f5d1..a53073f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -180,6 +180,151 @@ func TestProjectRoot_Fallback(t *testing.T) { } } +func TestHookConfig_TOML_RoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + os.WriteFile(path, []byte(` +[[hooks]] +name = "log-tools" +event = "post_tool_use" +type = "command" +exec = "tee -a /tmp/tools.log" +timeout = "5s" +fail_open = true +tool_pattern = "bash*" +`), 0o644) + + cfg := Defaults() + if err := loadTOML(&cfg, path); err != nil { + t.Fatalf("loadTOML: %v", err) + } + if len(cfg.Hooks) != 1 { + t.Fatalf("len(Hooks) = %d, want 1", len(cfg.Hooks)) + } + h := cfg.Hooks[0] + if h.Name != "log-tools" { + t.Errorf("Name = %q", h.Name) + } + if h.Event != "post_tool_use" { + t.Errorf("Event = %q", h.Event) + } + if h.Type != "command" { + t.Errorf("Type = %q", h.Type) + } + if h.Exec != "tee -a /tmp/tools.log" { + t.Errorf("Exec = %q", h.Exec) + } + if h.Timeout != "5s" { + t.Errorf("Timeout = %q", h.Timeout) + } + if !h.FailOpen { + t.Error("FailOpen should be true") + } + if h.ToolPattern != "bash*" { + t.Errorf("ToolPattern = %q", h.ToolPattern) + } +} + +func TestHookConfig_MergeOrder(t *testing.T) { + globalDir := t.TempDir() + gnomaDir := filepath.Join(globalDir, "gnoma") + os.MkdirAll(gnomaDir, 0o755) + os.WriteFile(filepath.Join(gnomaDir, "config.toml"), []byte(` +[[hooks]] +name = "global-hook" +event = "pre_tool_use" +type = "command" +exec = "echo global" +`), 0o644) + + projectDir := t.TempDir() + pGnomaDir := filepath.Join(projectDir, ".gnoma") + os.MkdirAll(pGnomaDir, 0o755) + os.WriteFile(filepath.Join(pGnomaDir, "config.toml"), []byte(` +[[hooks]] +name = "project-hook" +event = "post_tool_use" +type = "command" +exec = "echo project" +`), 0o644) + + t.Setenv("XDG_CONFIG_HOME", globalDir) + origDir, _ := os.Getwd() + os.Chdir(projectDir) + defer os.Chdir(origDir) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.Hooks) != 2 { + t.Fatalf("len(Hooks) = %d, want 2", len(cfg.Hooks)) + } + // global hook first + if cfg.Hooks[0].Name != "global-hook" { + t.Errorf("Hooks[0].Name = %q, want global-hook", cfg.Hooks[0].Name) + } + if cfg.Hooks[1].Name != "project-hook" { + t.Errorf("Hooks[1].Name = %q, want project-hook", cfg.Hooks[1].Name) + } +} + +func TestHookConfig_ProjectOnly(t *testing.T) { + // No global hooks, project defines one. + projectDir := t.TempDir() + pGnomaDir := filepath.Join(projectDir, ".gnoma") + os.MkdirAll(pGnomaDir, 0o755) + os.WriteFile(filepath.Join(pGnomaDir, "config.toml"), []byte(` +[[hooks]] +name = "project-only" +event = "stop" +type = "command" +exec = "echo done" +`), 0o644) + + emptyGlobalDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", emptyGlobalDir) + origDir, _ := os.Getwd() + os.Chdir(projectDir) + defer os.Chdir(origDir) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.Hooks) != 1 || cfg.Hooks[0].Name != "project-only" { + t.Errorf("Hooks = %v, want [project-only]", cfg.Hooks) + } +} + +func TestHookConfig_GlobalOnly(t *testing.T) { + // Global defines a hook, no project config. + globalDir := t.TempDir() + gnomaDir := filepath.Join(globalDir, "gnoma") + os.MkdirAll(gnomaDir, 0o755) + os.WriteFile(filepath.Join(gnomaDir, "config.toml"), []byte(` +[[hooks]] +name = "global-only" +event = "session_start" +type = "command" +exec = "echo start" +`), 0o644) + + projectDir := t.TempDir() // no .gnoma dir + t.Setenv("XDG_CONFIG_HOME", globalDir) + origDir, _ := os.Getwd() + os.Chdir(projectDir) + defer os.Chdir(origDir) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.Hooks) != 1 || cfg.Hooks[0].Name != "global-only" { + t.Errorf("Hooks = %v, want [global-only]", cfg.Hooks) + } +} + func TestLayeredLoad(t *testing.T) { // Set up global config globalDir := t.TempDir() diff --git a/internal/config/load.go b/internal/config/load.go index 25a9dc8..a243841 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -23,12 +23,22 @@ func Load() (*Config, error) { if err := loadTOML(&cfg, globalPath); err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("loading global config %s: %w", globalPath, err) } + // Deep copy global hooks before the project layer. + // toml.Decode may reuse the backing array, so a plain slice-header copy + // would alias into whatever the project decode writes. + // Also reset cfg.Hooks to nil so the project layer starts clean — + // if the project config is absent, cfg.Hooks stays nil and the append + // below just returns the global hooks unchanged. + globalHooks := append([]HookConfig(nil), cfg.Hooks...) + cfg.Hooks = nil // Layer 2: Project config projectPath := projectConfigPath() if err := loadTOML(&cfg, projectPath); err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("loading project config %s: %w", projectPath, err) } + // User hooks run first, project hooks after. + cfg.Hooks = append(globalHooks, cfg.Hooks...) // Layer 3: Environment variables applyEnv(&cfg)