feat: hook config schema with user+project merge ordering

This commit is contained in:
2026-04-07 00:50:53 +02:00
parent 5701bc2033
commit d27dea5ee0
3 changed files with 178 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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)