feat: hook config schema with user+project merge ordering
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user