diff --git a/cmd/kontext/setup.go b/cmd/kontext/setup.go index 922d13e..15b3778 100644 --- a/cmd/kontext/setup.go +++ b/cmd/kontext/setup.go @@ -17,8 +17,8 @@ func setupCmd() *cobra.Command { Long: `Connect this Mac to your Kontext organization (self-serve managed observe). Setup asks for the install token created in the Kontext dashboard, stores it -in your login keychain, installs the Claude Code hooks, and starts a -background agent that streams Claude Code activity to your workspace. +in your login keychain, installs hooks for supported local agents, and starts +a background agent that streams agent activity to your workspace. Re-running setup is safe: it rotates the stored token and restarts the agent. Use --uninstall to remove everything setup installed (the kontext binary diff --git a/cmd/kontext/setup_test.go b/cmd/kontext/setup_test.go index 59ee7a3..c4332a4 100644 --- a/cmd/kontext/setup_test.go +++ b/cmd/kontext/setup_test.go @@ -1,6 +1,7 @@ package main import ( + "strings" "testing" "github.com/kontext-security/kontext-cli/internal/setup" @@ -41,6 +42,9 @@ func TestSetupCmdRegistered(t *testing.T) { if cmd.Use != "setup" { t.Fatalf("Use = %q", cmd.Use) } + if !strings.Contains(cmd.Long, "hooks for supported local agents") { + t.Fatalf("setup long help is not agent-oriented:\n%s", cmd.Long) + } } func TestSetupCmdSilencesUsageForRuntimeErrors(t *testing.T) { diff --git a/internal/agenthooks/config.go b/internal/agenthooks/config.go index 52be9af..a7197cb 100644 --- a/internal/agenthooks/config.go +++ b/internal/agenthooks/config.go @@ -1,6 +1,7 @@ package agenthooks import ( + "encoding/json" "errors" "maps" ) @@ -22,6 +23,20 @@ type Config struct { HooksDescription string } +// ToJSONAny round-trips a typed value through JSON so it lands in a generic +// map with the same shape WriteJSONFile will serialize. +func ToJSONAny(value any) (any, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + var out any + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + return out, nil +} + // HooksMap returns the hooks object from the config. A missing hooks key is an // empty map; a non-object hooks value is left untouched and reported as an // error because the file belongs to the user. diff --git a/internal/agenthooks/jsonfile.go b/internal/agenthooks/jsonfile.go new file mode 100644 index 0000000..385e0e1 --- /dev/null +++ b/internal/agenthooks/jsonfile.go @@ -0,0 +1,124 @@ +package agenthooks + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "time" +) + +// ReadJSONFile parses a hook settings file into a generic map so unknown keys +// survive a read-merge-write round trip. A missing file is an empty map. +func ReadJSONFile(path, description string) (map[string]any, error) { + settings := map[string]any{} + raw, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return settings, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(raw, &settings); err != nil { + return nil, fmt.Errorf("parse %s: %w", description, err) + } + return settings, nil +} + +// WriteJSONFile writes a hook settings map atomically, preserving existing +// permission bits. If path is a symlink, the symlink is left in place and its +// resolved target is rewritten. +func WriteJSONFile(path string, settings map[string]any) error { + writePath := path + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + target, err := filepath.EvalSymlinks(path) + if err != nil { + return err + } + writePath = target + } else if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + + mode := fs.FileMode(0o600) + if info, err := os.Stat(writePath); err == nil { + mode = info.Mode().Perm() + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + + temp, err := os.CreateTemp(filepath.Dir(writePath), ".settings-*.tmp") + if err != nil { + return err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if err := temp.Chmod(mode); err != nil { + temp.Close() + return err + } + if _, err := temp.Write(append(data, '\n')); err != nil { + temp.Close() + return err + } + if err := temp.Sync(); err != nil { + temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + return os.Rename(tempPath, writePath) +} + +// BackupFile copies path aside with a timestamped suffix and matching +// permissions. Missing files are a no-op. +func BackupFile(path, label string) error { + info, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + backupPathPrefix := fmt.Sprintf("%s.%s-backup-%s", path, label, time.Now().UTC().Format("20060102T150405.000000000Z")) + var file *os.File + for attempt := 0; attempt < 100; attempt++ { + backupPath := backupPathPrefix + if attempt > 0 { + backupPath = fmt.Sprintf("%s-%d", backupPathPrefix, attempt) + } + file, err = os.OpenFile(backupPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm()) + if errors.Is(err, fs.ErrExist) { + continue + } + if err != nil { + return err + } + break + } + if file == nil { + return fmt.Errorf("create backup for %s: too many timestamp collisions", path) + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + return err + } + return file.Close() +} + +// ShellQuote quotes one shell token for hook command strings. +func ShellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} diff --git a/internal/agenthooks/jsonfile_test.go b/internal/agenthooks/jsonfile_test.go new file mode 100644 index 0000000..2144f29 --- /dev/null +++ b/internal/agenthooks/jsonfile_test.go @@ -0,0 +1,136 @@ +package agenthooks + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestReadWriteJSONFilePreservesPermissions(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "hooks.json") + + if err := WriteJSONFile(path, map[string]any{"a": float64(1)}); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("new file mode = %v, want 0600", info.Mode().Perm()) + } + + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + if err := WriteJSONFile(path, map[string]any{"a": float64(2)}); err != nil { + t.Fatal(err) + } + info, err = os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o644 { + t.Fatalf("rewritten file mode = %v, want 0644 preserved", info.Mode().Perm()) + } + + got, err := ReadJSONFile(path, "test hooks") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, map[string]any{"a": float64(2)}) { + t.Fatalf("round trip = %v", got) + } +} + +func TestWriteJSONFilePreservesSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target-hooks.json") + firstLink := filepath.Join(dir, "first-hooks.json") + link := filepath.Join(dir, "hooks.json") + if err := os.WriteFile(target, []byte(`{"a":1}`), 0o640); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, firstLink); err != nil { + t.Fatal(err) + } + if err := os.Symlink(firstLink, link); err != nil { + t.Fatal(err) + } + + if err := WriteJSONFile(link, map[string]any{"a": float64(2)}); err != nil { + t.Fatal(err) + } + info, err := os.Lstat(link) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("hooks path is no longer a symlink: mode=%v", info.Mode()) + } + firstInfo, err := os.Lstat(firstLink) + if err != nil { + t.Fatal(err) + } + if firstInfo.Mode()&os.ModeSymlink == 0 { + t.Fatalf("intermediate hooks path is no longer a symlink: mode=%v", firstInfo.Mode()) + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"a": 2`) { + t.Fatalf("target content = %s, want rewritten target", data) + } +} + +func TestBackupFilePreservesModeAndContent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "hooks.json") + if err := os.WriteFile(path, []byte(`{"a":1}`), 0o640); err != nil { + t.Fatal(err) + } + if err := BackupFile(path, "kontext-setup"); err != nil { + t.Fatal(err) + } + if err := BackupFile(path, "kontext-setup"); err != nil { + t.Fatal(err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + backups := 0 + for _, entry := range entries { + if !strings.Contains(entry.Name(), "kontext-setup-backup-") { + continue + } + backups++ + backupPath := filepath.Join(dir, entry.Name()) + info, err := os.Stat(backupPath) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o640 { + t.Fatalf("backup mode = %v, want original 0640", info.Mode().Perm()) + } + data, err := os.ReadFile(backupPath) + if err != nil { + t.Fatal(err) + } + if string(data) != `{"a":1}` { + t.Fatalf("backup content = %s", data) + } + } + if backups != 2 { + t.Fatalf("backup count = %d, want 2", backups) + } + + if err := BackupFile(filepath.Join(dir, "absent.json"), "x"); err != nil { + t.Fatal(err) + } +} diff --git a/internal/claudemanaged/settings.go b/internal/claudemanaged/settings.go index b00a0ba..864b759 100644 --- a/internal/claudemanaged/settings.go +++ b/internal/claudemanaged/settings.go @@ -314,11 +314,7 @@ func validateAsync(event Event, async *bool) error { } func hookCommand(kontextBinary, alias string) string { - return shellQuote(kontextBinary) + " hook " + shellQuote(alias) -} - -func shellQuote(value string) string { - return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" + return agenthooks.ShellQuote(kontextBinary) + " hook " + agenthooks.ShellQuote(alias) } func isAllMatcher(value string) bool { diff --git a/internal/claudemanaged/usersettings.go b/internal/claudemanaged/usersettings.go index 71b144c..88689b4 100644 --- a/internal/claudemanaged/usersettings.go +++ b/internal/claudemanaged/usersettings.go @@ -1,14 +1,9 @@ package claudemanaged import ( - "encoding/json" - "errors" - "fmt" - "io/fs" "os" "path/filepath" "strings" - "time" "github.com/kontext-security/kontext-cli/internal/agenthooks" "github.com/kontext-security/kontext-cli/internal/hook" @@ -36,18 +31,7 @@ func UserSettingsPath() (string, error) { // ReadUserSettings parses the settings file into a generic map so unknown // keys survive a read-merge-write round trip. A missing file is an empty map. func ReadUserSettings(path string) (map[string]any, error) { - settings := map[string]any{} - raw, err := os.ReadFile(path) - if errors.Is(err, fs.ErrNotExist) { - return settings, nil - } - if err != nil { - return nil, err - } - if err := json.Unmarshal(raw, &settings); err != nil { - return nil, fmt.Errorf("parse Claude settings: %w", err) - } - return settings, nil + return agenthooks.ReadJSONFile(path, "Claude settings") } // WriteUserSettings writes the settings back atomically (temp file + rename, @@ -55,90 +39,13 @@ func ReadUserSettings(path string) (map[string]any, error) { // preserving the existing file's permission bits (a user may keep their // settings private); new files are created 0600. func WriteUserSettings(path string, settings map[string]any) error { - writePath := path - if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { - target, err := filepath.EvalSymlinks(path) - if err != nil { - return err - } - writePath = target - } else if err != nil && !errors.Is(err, fs.ErrNotExist) { - return err - } - - mode := fs.FileMode(0o600) - if info, err := os.Stat(writePath); err == nil { - mode = info.Mode().Perm() - } else if !errors.Is(err, fs.ErrNotExist) { - return err - } - data, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return err - } - - temp, err := os.CreateTemp(filepath.Dir(writePath), ".settings-*.tmp") - if err != nil { - return err - } - tempPath := temp.Name() - defer os.Remove(tempPath) - if err := temp.Chmod(mode); err != nil { - temp.Close() - return err - } - if _, err := temp.Write(append(data, '\n')); err != nil { - temp.Close() - return err - } - if err := temp.Sync(); err != nil { - temp.Close() - return err - } - if err := temp.Close(); err != nil { - return err - } - return os.Rename(tempPath, writePath) + return agenthooks.WriteJSONFile(path, settings) } // BackupUserSettings copies the file aside (timestamped, same permissions) // before a mutation. Missing file is a no-op. func BackupUserSettings(path, label string) error { - info, err := os.Stat(path) - if errors.Is(err, fs.ErrNotExist) { - return nil - } - if err != nil { - return err - } - data, err := os.ReadFile(path) - if err != nil { - return err - } - backupPathPrefix := fmt.Sprintf("%s.%s-backup-%s", path, label, time.Now().UTC().Format("20060102T150405.000000000Z")) - var file *os.File - for attempt := 0; attempt < 100; attempt++ { - backupPath := backupPathPrefix - if attempt > 0 { - backupPath = fmt.Sprintf("%s-%d", backupPathPrefix, attempt) - } - file, err = os.OpenFile(backupPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm()) - if errors.Is(err, fs.ErrExist) { - continue - } - if err != nil { - return err - } - break - } - if file == nil { - return fmt.Errorf("create backup for %s: too many timestamp collisions", path) - } - if _, err := file.Write(data); err != nil { - _ = file.Close() - return err - } - return file.Close() + return agenthooks.BackupFile(path, label) } // IsGuardHookCommand reports whether a hook command belongs to Kontext Guard diff --git a/internal/codexmanaged/hooks.go b/internal/codexmanaged/hooks.go new file mode 100644 index 0000000..9eae28f --- /dev/null +++ b/internal/codexmanaged/hooks.go @@ -0,0 +1,252 @@ +package codexmanaged + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/kontext-security/kontext-cli/internal/agenthooks" + "github.com/kontext-security/kontext-cli/internal/hook" +) + +const ( + DefaultKontextBinary = "/usr/local/bin/kontext" + DefaultHookTimeout = 20 +) + +var SupportedEvents = []hook.EventAlias{ + {Name: hook.HookSessionStart, Alias: "session-start"}, + {Name: hook.HookPreToolUse, Alias: "pre-tool-use"}, + {Name: hook.HookPostToolUse, Alias: "post-tool-use"}, + {Name: hook.HookUserPromptSubmit, Alias: "user-prompt-submit"}, + {Name: hook.HookStop, Alias: "stop"}, +} + +type Settings struct { + Hooks map[string][]MatcherGroup `json:"hooks"` +} + +type MatcherGroup struct { + Matcher string `json:"matcher"` + Hooks []Handler `json:"hooks"` +} + +type Handler struct { + Type string `json:"type"` + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Timeout int `json:"timeout,omitempty"` + Async *bool `json:"async,omitempty"` +} + +// UserHooksPath returns ~/.codex/hooks.json, creating ~/.codex. +func UserHooksPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + codexDir := filepath.Join(home, ".codex") + if err := os.MkdirAll(codexDir, 0o755); err != nil { + return "", err + } + return filepath.Join(codexDir, "hooks.json"), nil +} + +// UserHooksPathNoCreate returns ~/.codex/hooks.json without creating ~/.codex. +func UserHooksPathNoCreate() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".codex", "hooks.json"), nil +} + +func ReadHooks(path string) (map[string]any, error) { + return agenthooks.ReadJSONFile(path, "Codex hooks") +} + +func WriteHooks(path string, settings map[string]any) error { + return agenthooks.WriteJSONFile(path, settings) +} + +func BackupHooks(path, label string) error { + return agenthooks.BackupFile(path, label) +} + +func Template(kontextBinary string) Settings { + kontextBinary = strings.TrimSpace(kontextBinary) + if kontextBinary == "" { + kontextBinary = DefaultKontextBinary + } + settings := Settings{Hooks: make(map[string][]MatcherGroup, len(SupportedEvents))} + for _, event := range SupportedEvents { + settings.Hooks[event.Name.String()] = []MatcherGroup{{ + Matcher: "", + Hooks: []Handler{{ + Type: "command", + Command: hookCommand(kontextBinary, event.Alias), + Timeout: DefaultHookTimeout, + }}, + }} + } + return settings +} + +func TemplateJSON(kontextBinary string) ([]byte, error) { + data, err := json.MarshalIndent(Template(kontextBinary), "", " ") + if err != nil { + return nil, fmt.Errorf("marshal Codex hooks template: %w", err) + } + return append(data, '\n'), nil +} + +func Validate(data []byte, kontextBinary string) error { + kontextBinary = strings.TrimSpace(kontextBinary) + if kontextBinary == "" { + kontextBinary = DefaultKontextBinary + } + + var settings Settings + if err := json.Unmarshal(data, &settings); err != nil { + return fmt.Errorf("parse Codex hooks: %w", err) + } + + var problems []string + if settings.Hooks == nil { + problems = append(problems, "hooks missing") + } + for _, event := range SupportedEvents { + if err := validateEvent(settings.Hooks[event.Name.String()], event, kontextBinary); err != nil { + problems = append(problems, err.Error()) + } + } + if len(problems) > 0 { + return errors.New(strings.Join(problems, "; ")) + } + return nil +} + +// IsManagedHookCommand reports whether a hook command is one of OUR Codex +// self-serve hooks. Matching on the alias rather than the exact binary path +// lets setup replace stale entries after the binary moves. +func IsManagedHookCommand(command string) bool { + fields, ok := agenthooks.SplitCommand(command) + if !ok || len(fields) != 5 { + return false + } + if filepath.Base(fields[0]) != "kontext" || fields[1] != "hook" || fields[2] != "--agent" || fields[3] != "codex" { + return false + } + for _, event := range SupportedEvents { + if fields[4] == event.Alias { + return true + } + } + return false +} + +func isManagedHookHandler(handler agenthooks.CommandHandler) bool { + if len(handler.Args) > 0 { + return false + } + return IsManagedHookCommand(handler.Command) +} + +func managedHookPlan(kontextBinary string) agenthooks.Plan { + kontextBinary = strings.TrimSpace(kontextBinary) + if kontextBinary == "" { + kontextBinary = DefaultKontextBinary + } + + events := make(map[hook.HookName]agenthooks.EventPlan, len(SupportedEvents)) + for _, event := range SupportedEvents { + events[event.Name] = agenthooks.EventPlan{ + Match: agenthooks.MatchSpec{ + Pattern: "", + }, + Command: agenthooks.CommandHook{ + Command: hookCommand(kontextBinary, event.Alias), + Timeout: DefaultHookTimeout, + }, + Placement: agenthooks.PlacementAppend, + } + } + + return agenthooks.Plan{ + Version: agenthooks.SchemaVersionV1, + Provider: agenthooks.ProviderCodex, + Owner: agenthooks.OwnerKontextManagedObserve, + Events: events, + } +} + +func MergeManagedHooks(settings map[string]any, kontextBinary string) error { + config := agenthooks.Config{ + Settings: settings, + HooksDescription: "hooks.json hooks", + } + return config.Merge(managedHookPlan(kontextBinary), isManagedHookHandler) +} + +func RemoveManagedHooks(settings map[string]any) error { + config := agenthooks.Config{ + Settings: settings, + HooksDescription: "hooks.json hooks", + } + return config.Remove(managedHookPlan(DefaultKontextBinary), isManagedHookHandler) +} + +func validateEvent(groups []MatcherGroup, event hook.EventAlias, kontextBinary string) error { + if len(groups) == 0 { + return fmt.Errorf("%s hook missing", event.Name) + } + + foundValid := false + firstRestrictiveMatcher := "" + for _, group := range groups { + for _, handler := range group.Hooks { + if handler.Command != hookCommand(kontextBinary, event.Alias) { + continue + } + if handler.Type != "command" { + return fmt.Errorf("%s Kontext handler type = %q, want command", event.Name, handler.Type) + } + if len(handler.Args) > 0 { + return fmt.Errorf("%s Kontext handler args must be omitted", event.Name) + } + if handler.Timeout <= 0 { + return fmt.Errorf("%s Kontext handler timeout must be positive", event.Name) + } + if handler.Async != nil && *handler.Async { + return fmt.Errorf("%s Kontext handler async must not be true", event.Name) + } + if !isAllMatcher(group.Matcher) { + if firstRestrictiveMatcher == "" { + firstRestrictiveMatcher = group.Matcher + } + continue + } + foundValid = true + } + } + + if foundValid { + return nil + } + if firstRestrictiveMatcher != "" { + return fmt.Errorf("%s Kontext command hook uses matcher %q; use an empty matcher for full event coverage", event.Name, firstRestrictiveMatcher) + } + return fmt.Errorf("%s Kontext command hook missing for %s", event.Name, kontextBinary) +} + +func hookCommand(kontextBinary, alias string) string { + return agenthooks.ShellQuote(kontextBinary) + " hook --agent " + agenthooks.ShellQuote("codex") + " " + agenthooks.ShellQuote(alias) +} + +func isAllMatcher(value string) bool { + matcher := strings.TrimSpace(value) + return matcher == "" || matcher == "*" +} diff --git a/internal/codexmanaged/hooks_test.go b/internal/codexmanaged/hooks_test.go new file mode 100644 index 0000000..1308b33 --- /dev/null +++ b/internal/codexmanaged/hooks_test.go @@ -0,0 +1,241 @@ +package codexmanaged + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +const testBinary = "/opt/homebrew/bin/kontext" + +func decode(t *testing.T, raw string) map[string]any { + t.Helper() + var out map[string]any + if err := json.Unmarshal([]byte(raw), &out); err != nil { + t.Fatal(err) + } + return out +} + +func eventGroups(t *testing.T, settings map[string]any, event string) []any { + t.Helper() + hooks, ok := settings["hooks"].(map[string]any) + if !ok { + t.Fatalf("hooks missing or not an object: %T", settings["hooks"]) + } + groups, ok := hooks[event].([]any) + if !ok { + t.Fatalf("event %s missing or not a list: %T", event, hooks[event]) + } + return groups +} + +func TestMergeManagedHooksIntoEmptySettings(t *testing.T) { + settings := map[string]any{} + if err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatalf("MergeManagedHooks() error = %v", err) + } + + for _, event := range SupportedEvents { + groups := eventGroups(t, settings, event.Name.String()) + if len(groups) != 1 { + t.Fatalf("%s groups = %d, want 1", event.Name, len(groups)) + } + handler := groups[0].(map[string]any)["hooks"].([]any)[0].(map[string]any) + wantCommand := "'" + testBinary + "' hook --agent 'codex' '" + event.Alias + "'" + if handler["command"] != wantCommand { + t.Fatalf("%s command = %q, want %q", event.Name, handler["command"], wantCommand) + } + if _, present := handler["async"]; present { + t.Fatalf("%s handler contains async: %v", event.Name, handler) + } + } + + data, err := json.Marshal(map[string]any{"hooks": settings["hooks"]}) + if err != nil { + t.Fatal(err) + } + if err := Validate(data, testBinary); err != nil { + t.Fatalf("merged hooks fail Validate: %v", err) + } +} + +func TestMergeManagedHooksPreservesForeignContent(t *testing.T) { + settings := decode(t, `{ + "telemetry": {"enabled": true}, + "hooks": { + "PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": "/usr/local/bin/other-tool check"}]} + ], + "Stop": [ + {"hooks": [{"type": "command", "command": "say done"}]} + ] + }, + "unknownKey": 42 + }`) + + if err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } + + if settings["unknownKey"] != float64(42) { + t.Fatalf("unknownKey clobbered: %v", settings["unknownKey"]) + } + if _, ok := settings["telemetry"].(map[string]any); !ok { + t.Fatal("telemetry clobbered") + } + + groups := eventGroups(t, settings, "PreToolUse") + if len(groups) != 2 { + t.Fatalf("PreToolUse groups = %d, want foreign + ours", len(groups)) + } + if groups[0].(map[string]any)["matcher"] != "Bash" { + t.Fatalf("foreign group reordered or modified: %v", groups[0]) + } + stop := eventGroups(t, settings, "Stop") + if len(stop) != 2 { + t.Fatalf("Stop groups = %d, want foreign + ours", len(stop)) + } + if stop[0].(map[string]any)["hooks"].([]any)[0].(map[string]any)["command"] != "say done" { + t.Fatalf("foreign Stop hook reordered or modified: %v", stop[0]) + } +} + +func TestMergeManagedHooksReplacesStaleBinaryPath(t *testing.T) { + settings := decode(t, `{ + "hooks": { + "PreToolUse": [ + {"matcher": "", "hooks": [{"type": "command", "command": "'/Applications/Kontext CLI/kontext' hook --agent 'codex' 'pre-tool-use'", "timeout": 20}]} + ] + } + }`) + + if err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } + + groups := eventGroups(t, settings, "PreToolUse") + if len(groups) != 1 { + t.Fatalf("PreToolUse groups = %d, want stale entry replaced, not duplicated", len(groups)) + } + handler := groups[0].(map[string]any)["hooks"].([]any)[0].(map[string]any) + if !strings.Contains(handler["command"].(string), testBinary) { + t.Fatalf("stale binary path not replaced: %v", handler["command"]) + } +} + +func TestMergeManagedHooksIsIdempotent(t *testing.T) { + settings := map[string]any{} + if err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } + first, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + if err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } + second, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + if string(first) != string(second) { + t.Fatalf("double merge is not a fixpoint:\n%s\n%s", first, second) + } +} + +func TestRemoveManagedHooksLeavesForeignContent(t *testing.T) { + settings := map[string]any{ + "telemetry": map[string]any{"enabled": true}, + } + if err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } + hooks := settings["hooks"].(map[string]any) + pre := hooks["PreToolUse"].([]any) + hooks["PreToolUse"] = append(pre, map[string]any{ + "matcher": "Edit", + "hooks": []any{map[string]any{"type": "command", "command": "lint-check"}}, + }) + + if err := RemoveManagedHooks(settings); err != nil { + t.Fatal(err) + } + + hooks = settings["hooks"].(map[string]any) + for _, event := range SupportedEvents { + if event.Name.String() == "PreToolUse" { + continue + } + if _, present := hooks[event.Name.String()]; present { + t.Fatalf("%s not pruned after removal", event.Name) + } + } + pre = hooks["PreToolUse"].([]any) + if len(pre) != 1 || pre[0].(map[string]any)["matcher"] != "Edit" { + t.Fatalf("foreign PreToolUse hook lost: %v", pre) + } + if _, ok := settings["telemetry"]; !ok { + t.Fatal("telemetry clobbered by removal") + } +} + +func TestRemoveManagedHooksDropsEmptyHooksKey(t *testing.T) { + settings := map[string]any{} + if err := MergeManagedHooks(settings, testBinary); err != nil { + t.Fatal(err) + } + if err := RemoveManagedHooks(settings); err != nil { + t.Fatal(err) + } + if _, present := settings["hooks"]; present { + t.Fatalf("empty hooks key left behind: %v", settings["hooks"]) + } +} + +func TestIsManagedHookCommand(t *testing.T) { + cases := []struct { + command string + want bool + }{ + {"'/usr/local/bin/kontext' hook --agent 'codex' 'pre-tool-use'", true}, + {"'/Users/o'\\''brien/bin/kontext' hook --agent 'codex' 'pre-tool-use'", true}, + {"'/Applications/Kontext CLI/kontext' hook --agent 'codex' 'session-start'", true}, + {"'/opt/homebrew/bin/kontext' hook --agent codex user-prompt-submit", true}, + {"'/opt/homebrew/bin/kontext' hook --agent codex stop", true}, + {"'/Applications/Kontext CLI/kontext' hook --agent 'codex' 'post-tool-use-failure'", false}, + {"'/Applications/Kontext CLI/kontext' hook --agent 'codex' 'session-end'", false}, + {"'/usr/local/bin/kontext' hook --agent 'codex' 'post-tool-use' --mode observe", false}, + {"'/usr/local/bin/kontext' hook 'pre-tool-use'", false}, + {"'/usr/local/bin/kontext' hook --agent 'claude' 'pre-tool-use'", false}, + {"kontext guard hook claude-code", false}, + {"/usr/local/bin/other-tool hook --agent codex pre-tool-use", false}, + {"/usr/local/bin/not-kontext hook --agent codex pre-tool-use", false}, + {"say done", false}, + {"", false}, + } + for _, tc := range cases { + if got := IsManagedHookCommand(tc.command); got != tc.want { + t.Errorf("IsManagedHookCommand(%q) = %v, want %v", tc.command, got, tc.want) + } + } +} + +func TestUserHooksPathCreatesCodexDirectory(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + path, err := UserHooksPath() + if err != nil { + t.Fatal(err) + } + if path != filepath.Join(home, ".codex", "hooks.json") { + t.Fatalf("UserHooksPath() = %q", path) + } + if _, err := os.Stat(filepath.Join(home, ".codex")); err != nil { + t.Fatalf(".codex dir missing: %v", err) + } +} diff --git a/internal/managedobserve/updater_test.go b/internal/managedobserve/updater_test.go index 60bf4a5..f3b95da 100644 --- a/internal/managedobserve/updater_test.go +++ b/internal/managedobserve/updater_test.go @@ -241,12 +241,23 @@ func TestRunHomebrewUpdaterLogsUpdateFailureAndKeepsRunning(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) - defer cancel() upgraded := make(chan struct{}, 1) - go runHomebrewUpdater(ctx, homebrewUpdaterConfigValue{ - brewPath: "/opt/homebrew/bin/brew", - interval: time.Millisecond, - }, diagnostic.New(channelWriter{ch: logs}, true), upgraded) + done := make(chan struct{}) + go func() { + defer close(done) + runHomebrewUpdater(ctx, homebrewUpdaterConfigValue{ + brewPath: "/opt/homebrew/bin/brew", + interval: time.Millisecond, + }, diagnostic.New(channelWriter{ch: logs}, true), upgraded) + }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timed out waiting for updater shutdown") + } + }) deadline := time.After(time.Second) for { diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 76b30e3..76cf0d6 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -1,8 +1,8 @@ // Package setup implements `kontext setup`: connecting a single Mac to a // Kontext organization without MDM. It produces the same managed-observe // pipeline as an enterprise package install — managed config, installation -// identity, Claude Code hooks, LaunchAgent running the daemon — but at user -// scope (~/Library, ~/.claude) with the install token in the login keychain. +// identity, agent hooks, LaunchAgent running the daemon — but at user scope +// (~/Library, ~/.claude, ~/.codex) with the install token in the login keychain. package setup import ( @@ -26,6 +26,7 @@ import ( "golang.org/x/term" "github.com/kontext-security/kontext-cli/internal/claudemanaged" + "github.com/kontext-security/kontext-cli/internal/codexmanaged" "github.com/kontext-security/kontext-cli/internal/installation" "github.com/kontext-security/kontext-cli/internal/managedconfig" "github.com/kontext-security/kontext-cli/internal/managedobserve" @@ -121,6 +122,13 @@ func Run(ctx context.Context, opts Options) error { } fmt.Fprintln(opts.Stdout, "Kontext setup") + if err := preflightLegacyUserHooks(); err != nil { + return err + } + if err := preflightCodexUserHooks(binary); err != nil { + return err + } + ok, err := prepareManagedEnvironment(ctx, opts, settingsData) if err != nil { return err @@ -128,9 +136,6 @@ func Run(ctx context.Context, opts Options) error { if !ok { return nil } - if err := preflightLegacyUserHooks(); err != nil { - return err - } cloudURL := strings.TrimSpace(opts.CloudURL) if cloudURL == "" { @@ -200,6 +205,13 @@ func Run(ctx context.Context, opts Options) error { } fmt.Fprintf(opts.Stdout, " ✓ Claude Code managed hooks installed (%s)\n", settingsPath) + codexHooksPath, err := installCodexUserHooks(binary) + if err != nil { + return fmt.Errorf("install Codex hooks: %w\n\nFix or move ~/.codex/hooks.json, then rerun setup.", err) + } + fmt.Fprintf(opts.Stdout, " ✓ Codex hooks installed (%s)\n", codexHooksPath) + fmt.Fprintln(opts.Stderr, "note: Codex hooks require review before they run; open `/hooks` in Codex to trust the Kontext hooks.") + var plistPath, logPath string err = runWithStatus(opts.Stdout, "Installing background agent", func() error { var err error @@ -683,6 +695,61 @@ func preflightLegacyUserHooks() error { return nil } +func preflightCodexUserHooks(binary string) error { + path, err := codexmanaged.UserHooksPathNoCreate() + if err != nil { + return fmt.Errorf("check Codex hooks: %w", err) + } + if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return fmt.Errorf("check Codex hooks: %w", err) + } + settings, err := codexmanaged.ReadHooks(path) + if err != nil { + return fmt.Errorf("check Codex hooks: %w\n\nFix or move ~/.codex/hooks.json, then rerun setup.", err) + } + if err := codexmanaged.MergeManagedHooks(settings, binary); err != nil { + return fmt.Errorf("check Codex hooks: %w\n\nFix or move ~/.codex/hooks.json, then rerun setup.", err) + } + return nil +} + +func installCodexUserHooks(binary string) (string, error) { + path, err := codexmanaged.UserHooksPath() + if err != nil { + return "", err + } + before, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + before = nil + } else if err != nil { + return "", err + } + settings, err := codexmanaged.ReadHooks(path) + if err != nil { + return "", err + } + if err := codexmanaged.MergeManagedHooks(settings, binary); err != nil { + return "", err + } + after, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return "", err + } + after = append(after, '\n') + if bytes.Equal(before, after) { + return path, nil + } + if err := codexmanaged.BackupHooks(path, settingsBackupLabel); err != nil { + return "", err + } + if err := codexmanaged.WriteHooks(path, settings); err != nil { + return "", err + } + return path, nil +} + func waitForDaemon(out io.Writer) error { return runWithStatus(out, "Waiting for background agent", probeDaemon) } diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 163c34b..41f2679 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -15,6 +15,7 @@ import ( "time" "github.com/kontext-security/kontext-cli/internal/claudemanaged" + "github.com/kontext-security/kontext-cli/internal/codexmanaged" "github.com/kontext-security/kontext-cli/internal/installation" "github.com/kontext-security/kontext-cli/internal/managedconfig" ) @@ -205,6 +206,15 @@ func TestRunFullFlow(t *testing.T) { if _, err := os.Lstat(filepath.Join(h.home, ".claude", "settings.json")); !os.IsNotExist(err) { t.Fatalf("setup created user settings file: %v", err) } + codexHooksPath := filepath.Join(h.home, ".codex", "hooks.json") + codexHooks, err := codexmanaged.ReadHooks(codexHooksPath) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(map[string]any{"hooks": codexHooks["hooks"]}) + if err := codexmanaged.Validate(raw, "/opt/homebrew/bin/kontext"); err != nil { + t.Fatalf("Codex hooks invalid after setup: %v", err) + } // LaunchAgent plist written and lifecycle ordered bootout -> bootstrap // in the user's GUI domain. RunAtLoad starts the daemon after bootstrap. @@ -231,6 +241,7 @@ func TestRunFullFlow(t *testing.T) { "Workspace\n ✓ Acme (org_test)", "Mac\n ✓ Config written", " ✓ Claude Code managed hooks installed", + " ✓ Codex hooks installed", " • Installing background agent...", " • Waiting for background agent...", " ✓ Background agent running", @@ -244,6 +255,9 @@ func TestRunFullFlow(t *testing.T) { if strings.Contains(stdout, "Start a Claude Code session") { t.Fatalf("stdout still uses old ending:\n%s", stdout) } + if errOut := h.errOut.String(); !strings.Contains(errOut, "Codex hooks require review") || !strings.Contains(errOut, "/hooks") { + t.Fatalf("stderr missing Codex /hooks trust review note:\n%s", errOut) + } // The raw token never travels in argv — only via `security -i` stdin. for _, call := range h.calls { @@ -287,6 +301,21 @@ func TestRunIsIdempotent(t *testing.T) { if err := claudemanaged.Validate(data, "/opt/homebrew/bin/kontext"); err != nil { t.Fatalf("managed hooks invalid after re-run: %v", err) } + codexHooks, err := codexmanaged.ReadHooks(filepath.Join(h.home, ".codex", "hooks.json")) + if err != nil { + t.Fatal(err) + } + groups := codexHooks["hooks"].(map[string]any)["PreToolUse"].([]any) + if len(groups) != 1 { + t.Fatalf("Codex PreToolUse groups after re-run = %d, want 1", len(groups)) + } + backups, err := filepath.Glob(filepath.Join(h.home, ".codex", "hooks.json."+settingsBackupLabel+"-backup-*")) + if err != nil { + t.Fatal(err) + } + if len(backups) != 0 { + t.Fatalf("Codex hook backups after idempotent re-run = %d, want 0: %v", len(backups), backups) + } } func TestRunRejectsRevokedToken(t *testing.T) { @@ -307,6 +336,35 @@ func TestRunRejectsRevokedToken(t *testing.T) { } } +func TestRunRejectsMalformedCodexHooksBeforeWritingState(t *testing.T) { + h := newHarness(t) + allowLoopback(t) + codexHooksPath := filepath.Join(h.home, ".codex", "hooks.json") + if err := os.MkdirAll(filepath.Dir(codexHooksPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(codexHooksPath, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + + err := Run(context.Background(), h.options("tok-123", pingServer(t, "tok-123"))) + if err == nil || !strings.Contains(err.Error(), "check Codex hooks") { + t.Fatalf("Run() error = %v, want Codex hook preflight failure", err) + } + if len(h.keychain) != 0 { + t.Fatal("keychain written despite malformed Codex hooks") + } + if _, err := os.Stat(managedconfig.UserPath()); !os.IsNotExist(err) { + t.Fatal("managed.json written despite malformed Codex hooks") + } + if _, err := os.Stat(installation.UserPath()); !os.IsNotExist(err) { + t.Fatal("installation identity written despite malformed Codex hooks") + } + if _, err := os.Stat(managedSettingsPath); !os.IsNotExist(err) { + t.Fatal("managed settings written despite malformed Codex hooks") + } +} + func TestRunRefusesMDMManagedMac(t *testing.T) { h := newHarness(t) system := filepath.Join(h.home, "system-managed.json") @@ -734,6 +792,19 @@ func TestUninstallReversesSetupKeepingIdentity(t *testing.T) { if err := claudemanaged.WriteUserSettings(settingsPath, settings); err != nil { t.Fatal(err) } + codexHooksPath := filepath.Join(h.home, ".codex", "hooks.json") + codexHooks, err := codexmanaged.ReadHooks(codexHooksPath) + if err != nil { + t.Fatal(err) + } + codexEvents := codexHooks["hooks"].(map[string]any) + codexEvents["PreToolUse"] = append(codexEvents["PreToolUse"].([]any), map[string]any{ + "matcher": "Edit", + "hooks": []any{map[string]any{"type": "command", "command": "codex-lint-check"}}, + }) + if err := codexmanaged.WriteHooks(codexHooksPath, codexHooks); err != nil { + t.Fatal(err) + } if err := Uninstall(context.Background(), h.options("", pingServer(t, "unused"))); err != nil { t.Fatalf("Uninstall() error = %v", err) @@ -757,7 +828,7 @@ func TestUninstallReversesSetupKeepingIdentity(t *testing.T) { t.Fatalf("installation identity removed: %v", err) } // Legacy user hook gone, the foreign one intact. - settings, err := claudemanaged.ReadUserSettings(settingsPath) + settings, err = claudemanaged.ReadUserSettings(settingsPath) if err != nil { t.Fatal(err) } @@ -765,6 +836,14 @@ func TestUninstallReversesSetupKeepingIdentity(t *testing.T) { if len(pre) != 1 || pre[0].(map[string]any)["matcher"] != "Edit" { t.Fatalf("foreign hook lost or ours kept: %v", pre) } + codexHooks, err = codexmanaged.ReadHooks(codexHooksPath) + if err != nil { + t.Fatal(err) + } + pre = codexHooks["hooks"].(map[string]any)["PreToolUse"].([]any) + if len(pre) != 1 || pre[0].(map[string]any)["matcher"] != "Edit" { + t.Fatalf("foreign Codex hook lost or ours kept: %v", pre) + } // Idempotent: a second uninstall is clean. if err := Uninstall(context.Background(), h.options("", pingServer(t, "unused"))); err != nil { @@ -772,6 +851,34 @@ func TestUninstallReversesSetupKeepingIdentity(t *testing.T) { } } +func TestUninstallWarnsAndContinuesWhenCodexHooksMalformed(t *testing.T) { + h := newHarness(t) + allowLoopback(t) + server := pingServer(t, "tok-123") + if err := Run(context.Background(), h.options("tok-123", server)); err != nil { + t.Fatal(err) + } + codexHooksPath := filepath.Join(h.home, ".codex", "hooks.json") + if err := os.WriteFile(codexHooksPath, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + + h.errOut.Reset() + if err := Uninstall(context.Background(), h.options("", pingServer(t, "unused"))); err != nil { + t.Fatalf("Uninstall() error = %v", err) + } + + if len(h.keychain) != 0 { + t.Fatal("keychain item not removed after Codex hook warning") + } + if _, err := os.Stat(managedconfig.UserPath()); !os.IsNotExist(err) { + t.Fatal("managed.json not removed after Codex hook warning") + } + if !strings.Contains(h.errOut.String(), "warning: Codex hooks could not be removed") { + t.Fatalf("stderr missing Codex warning:\n%s", h.errOut.String()) + } +} + func TestUninstallKeepsManagedHooksWhenOrganizationManaged(t *testing.T) { h := newHarness(t) if err := os.MkdirAll(filepath.Dir(systemConfigPath), 0o755); err != nil { @@ -1184,6 +1291,13 @@ func TestUninstallWithoutSettingsFileDoesNotCreateOne(t *testing.T) { if _, err := os.Lstat(settingsPath); !os.IsNotExist(err) { t.Fatalf("uninstall created %s", settingsPath) } + codexHooksPath := filepath.Join(h.home, ".codex", "hooks.json") + if _, err := os.Lstat(codexHooksPath); !os.IsNotExist(err) { + t.Fatalf("uninstall created %s", codexHooksPath) + } + if _, err := os.Lstat(filepath.Dir(codexHooksPath)); !os.IsNotExist(err) { + t.Fatalf("uninstall created %s", filepath.Dir(codexHooksPath)) + } } func TestUninstallSurfacesKeychainFailures(t *testing.T) { diff --git a/internal/setup/uninstall.go b/internal/setup/uninstall.go index 1c060b9..3f5f917 100644 --- a/internal/setup/uninstall.go +++ b/internal/setup/uninstall.go @@ -8,6 +8,7 @@ import ( "path/filepath" "github.com/kontext-security/kontext-cli/internal/claudemanaged" + "github.com/kontext-security/kontext-cli/internal/codexmanaged" "github.com/kontext-security/kontext-cli/internal/installation" "github.com/kontext-security/kontext-cli/internal/managedconfig" ) @@ -97,6 +98,15 @@ func Uninstall(ctx context.Context, opts Options) error { fmt.Fprintln(opts.Stdout, " ✓ Claude Code hooks removed from ~/.claude/settings.json") } + removedCodexHooks, err := removeCodexUserHooks() + if err != nil { + fmt.Fprintf(opts.Stderr, "warning: Codex hooks could not be removed from ~/.codex/hooks.json (%v)\n", err) + } else if removedCodexHooks { + fmt.Fprintln(opts.Stdout, "✓ Codex hooks removed from ~/.codex/hooks.json") + } else { + fmt.Fprintln(opts.Stdout, " • No Codex hooks file; no hooks to remove") + } + if err := deleteKeychainTokens(ctx); err != nil { return err } @@ -120,6 +130,32 @@ func Uninstall(ctx context.Context, opts Options) error { return nil } +func removeCodexUserHooks() (bool, error) { + codexHooksPath, err := codexmanaged.UserHooksPathNoCreate() + if err != nil { + return false, err + } + if _, err := os.Lstat(codexHooksPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } else if err != nil { + return false, err + } + settings, err := codexmanaged.ReadHooks(codexHooksPath) + if err != nil { + return false, err + } + if err := codexmanaged.BackupHooks(codexHooksPath, settingsBackupLabel); err != nil { + return false, err + } + if err := codexmanaged.RemoveManagedHooks(settings); err != nil { + return false, err + } + if err := codexmanaged.WriteHooks(codexHooksPath, settings); err != nil { + return false, err + } + return true, nil +} + func removeSelfServeLaunchAgentIfPresent(ctx context.Context) (bool, string, error) { plistPath, err := launchAgentPath() if err != nil {