diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2990ef918..544915e22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,9 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Setup Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff @@ -58,7 +60,9 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Setup Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff @@ -74,8 +78,45 @@ jobs: - name: Upload performance report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: zero-performance-smoke path: dist/perf/perf-bench.json if-no-files-found: warn + + security: + name: Security & code health + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + with: + go-version-file: go.mod + cache: true + + # govulncheck and deadcode resolve a toolchain from their own modules, which + # can drop below the version go.mod requires and then fail to load our + # packages. Pin GOTOOLCHAIN to the go.mod toolchain so both run under it. + - name: Pin toolchain from go.mod + run: | + toolchain="$(awk '/^toolchain /{print $2}' go.mod)" + echo "GOTOOLCHAIN=${toolchain:-auto}" >> "$GITHUB_ENV" + + # Hard gate: fails the build when code reaches a known vulnerability. A stdlib + # CVE is cleared by a toolchain bump (see go.mod). May also flag a newly + # published advisory on an unrelated PR — intentional: do not ship known vulns. + - name: govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@v1.3.0 ./... + + # Advisory: reports functions unreachable from any cmd/* main so dormant + # code is visible in CI. Non-blocking while the dormant subsystems are + # still being wired or removed. + - name: deadcode (advisory) + continue-on-error: true + run: go run golang.org/x/tools/cmd/deadcode@v0.46.0 -test=false ./... diff --git a/.github/workflows/pr-auto-review.yml b/.github/workflows/pr-auto-review.yml index ccf215c50..6a007eac5 100644 --- a/.github/workflows/pr-auto-review.yml +++ b/.github/workflows/pr-auto-review.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 @@ -80,7 +80,7 @@ jobs: - name: Post review summary if: always() - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: ZERO_REVIEW_BODY_PATH: ${{ steps.review-summary.outputs.path }} with: diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 0bee6fcaa..f4f441ed1 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -26,11 +26,11 @@ jobs: steps: - name: Checkout if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }} - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Checkout requested ref if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }} - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ inputs.ref }} @@ -50,7 +50,7 @@ jobs: run: go run ./cmd/zero-release verify - name: Upload package - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: zero-${{ runner.os }}-${{ runner.arch }} path: dist/release/* diff --git a/.github/workflows/zero-action-smoke.yml b/.github/workflows/zero-action-smoke.yml index 4fc42d80a..5b9511725 100644 --- a/.github/workflows/zero-action-smoke.yml +++ b/.github/workflows/zero-action-smoke.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false diff --git a/go.mod b/go.mod index 1343f68d8..262134351 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/Gitlawb/zero go 1.24.2 -toolchain go1.24.13 +toolchain go1.26.4 require ( github.com/charmbracelet/bubbles v1.0.0 diff --git a/internal/cli/extensions.go b/internal/cli/extensions.go index 402b02119..9a4e2180e 100644 --- a/internal/cli/extensions.go +++ b/internal/cli/extensions.go @@ -129,6 +129,14 @@ func runHooks(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) i return exitCrash } return exitSuccess + case "add": + return runHooksAdd(args[1:], stdout, stderr, deps) + case "remove", "rm": + return runHooksRemove(args[1:], stdout, stderr, deps) + case "enable": + return runHooksToggle(args[1:], stdout, stderr, deps, false) + case "disable": + return runHooksToggle(args[1:], stdout, stderr, deps, true) default: return writeExecUsageError(stderr, fmt.Sprintf("unknown hooks subcommand %q", args[0])) } @@ -563,7 +571,11 @@ func writeHooksHelp(w io.Writer) error { zero hooks Commands: - list List configured Zero hooks + list List configured Zero hooks + add Add or update a hook + remove Remove a hook by id + enable Enable a hook by id + disable Disable a hook by id `) return err } diff --git a/internal/cli/hooks_manage.go b/internal/cli/hooks_manage.go new file mode 100644 index 000000000..e2bc41493 --- /dev/null +++ b/internal/cli/hooks_manage.go @@ -0,0 +1,343 @@ +package cli + +import ( + "fmt" + "io" + "strings" + + "github.com/Gitlawb/zero/internal/hooks" + "github.com/Gitlawb/zero/internal/redaction" +) + +// hookConfigStore resolves the writable hook config store for the chosen scope. +// Project scope (the default) targets /.zero/hooks.json; --user targets the +// user-level config. The store handles its own locking and atomic writes. +func hookConfigStore(deps appDeps, user bool) (*hooks.ConfigStore, string, error) { + cwd, err := deps.getwd() + if err != nil { + return nil, "", fmt.Errorf("failed to resolve workspace: %w", err) + } + paths, err := hooks.ResolvePaths(hooks.ResolvePathOptions{Cwd: cwd}) + if err != nil { + return nil, "", err + } + path := paths.ProjectConfigPath + if user { + path = paths.UserConfigPath + } + store, err := hooks.NewConfigStore(hooks.StoreOptions{ConfigPath: path}) + if err != nil { + return nil, "", err + } + return store, path, nil +} + +type hookAddOptions struct { + json bool + user bool + def hooks.Definition +} + +type hookTargetOptions struct { + json bool + user bool +} + +func runHooksAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + options, help, err := parseHooksAddArgs(args) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writeHooksAddHelp(stdout); err != nil { + return exitCrash + } + return exitSuccess + } + store, path, err := hookConfigStore(deps, options.user) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + saved, err := store.Upsert(options.def) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + if options.json { + payload := struct { + Hook hooks.Definition `json:"hook"` + ConfigPath string `json:"configPath"` + }{Hook: saved, ConfigPath: path} + if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil { + return exitCrash + } + return exitSuccess + } + if _, err := fmt.Fprintf(stdout, "Saved hook %s [%s] in %s.\n", saved.ID, saved.Event, path); err != nil { + return exitCrash + } + return exitSuccess +} + +func runHooksRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int { + options, positional, help, err := parseHooksTargetArgs(args, "remove") + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writeHooksTargetHelp(stdout, "remove"); err != nil { + return exitCrash + } + return exitSuccess + } + if len(positional) != 1 { + return writeExecUsageError(stderr, "usage: zero hooks remove [--user] [--json]") + } + hookID := positional[0] + store, path, err := hookConfigStore(deps, options.user) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + removed, err := store.Remove(hookID) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + if options.json { + payload := struct { + HookID string `json:"hookId"` + Removed bool `json:"removed"` + ConfigPath string `json:"configPath"` + }{HookID: hookID, Removed: removed, ConfigPath: path} + if err := writePrettyJSON(stdout, payload); err != nil { + return exitCrash + } + return exitSuccess + } + if removed { + if _, err := fmt.Fprintf(stdout, "Removed hook %s from %s.\n", hookID, path); err != nil { + return exitCrash + } + } else if _, err := fmt.Fprintf(stdout, "No hook named %s is configured in %s.\n", hookID, path); err != nil { + return exitCrash + } + return exitSuccess +} + +func runHooksToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, disabled bool) int { + commandName := "enable" + if disabled { + commandName = "disable" + } + options, positional, help, err := parseHooksTargetArgs(args, commandName) + if err != nil { + return writeExecUsageError(stderr, err.Error()) + } + if help { + if err := writeHooksTargetHelp(stdout, commandName); err != nil { + return exitCrash + } + return exitSuccess + } + if len(positional) != 1 { + return writeExecUsageError(stderr, fmt.Sprintf("usage: zero hooks %s [--user] [--json]", commandName)) + } + hookID := positional[0] + store, path, err := hookConfigStore(deps, options.user) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + found, err := store.SetEnabled(hookID, !disabled) + if err != nil { + return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash) + } + if !found { + return writeExecUsageError(stderr, fmt.Sprintf("no hook named %s is configured in %s", hookID, path)) + } + state := "enabled" + if disabled { + state = "disabled" + } + if options.json { + payload := struct { + HookID string `json:"hookId"` + Enabled bool `json:"enabled"` + ConfigPath string `json:"configPath"` + }{HookID: hookID, Enabled: !disabled, ConfigPath: path} + if err := writePrettyJSON(stdout, payload); err != nil { + return exitCrash + } + return exitSuccess + } + if _, err := fmt.Fprintf(stdout, "Hook %s is now %s in %s.\n", hookID, state, path); err != nil { + return exitCrash + } + return exitSuccess +} + +func parseHooksTargetArgs(args []string, command string) (hookTargetOptions, []string, bool, error) { + options := hookTargetOptions{} + positional := []string{} + for _, arg := range args { + switch arg { + case "-h", "--help", "help": + return options, positional, true, nil + case "--json": + options.json = true + case "--user": + options.user = true + default: + if strings.HasPrefix(arg, "-") { + return options, positional, false, execUsageError{fmt.Sprintf("unknown hooks %s flag %q", command, arg)} + } + positional = append(positional, arg) + } + } + return options, positional, false, nil +} + +func parseHooksAddArgs(args []string) (hookAddOptions, bool, error) { + options := hookAddOptions{} + // New hooks are enabled; persisted state is managed with enable/disable. + options.def.Enabled = true + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "-h" || arg == "--help" || arg == "help": + return options, true, nil + case arg == "--json": + options.json = true + case arg == "--user": + options.user = true + case arg == "--event": + value, err := requiredNextMCPFlagValue(args, &i, "--event") + if err != nil { + return options, false, err + } + options.def.Event = hooks.Event(value) + case strings.HasPrefix(arg, "--event="): + value, err := requiredInlineFlagValue(arg, "--event") + if err != nil { + return options, false, err + } + options.def.Event = hooks.Event(value) + case arg == "--command": + value, err := requiredNextMCPFlagValue(args, &i, "--command") + if err != nil { + return options, false, err + } + options.def.Command = value + case strings.HasPrefix(arg, "--command="): + value, err := requiredInlineFlagValue(arg, "--command") + if err != nil { + return options, false, err + } + options.def.Command = value + case arg == "--name": + value, err := requiredNextMCPFlagValue(args, &i, "--name") + if err != nil { + return options, false, err + } + options.def.Name = value + case strings.HasPrefix(arg, "--name="): + value, err := requiredInlineFlagValue(arg, "--name") + if err != nil { + return options, false, err + } + options.def.Name = value + case arg == "--description": + value, err := requiredNextMCPFlagValue(args, &i, "--description") + if err != nil { + return options, false, err + } + options.def.Description = value + case strings.HasPrefix(arg, "--description="): + value, err := requiredInlineFlagValue(arg, "--description") + if err != nil { + return options, false, err + } + options.def.Description = value + case arg == "--matcher": + value, err := requiredNextMCPFlagValue(args, &i, "--matcher") + if err != nil { + return options, false, err + } + options.def.Matcher = value + case strings.HasPrefix(arg, "--matcher="): + value, err := requiredInlineFlagValue(arg, "--matcher") + if err != nil { + return options, false, err + } + options.def.Matcher = value + case arg == "--arg": + value, err := requiredNextMCPFlagValue(args, &i, "--arg") + if err != nil { + return options, false, err + } + options.def.Args = append(options.def.Args, value) + case strings.HasPrefix(arg, "--arg="): + value, err := requiredInlineFlagValue(arg, "--arg") + if err != nil { + return options, false, err + } + options.def.Args = append(options.def.Args, value) + case strings.HasPrefix(arg, "-"): + return options, false, execUsageError{fmt.Sprintf("unknown hooks add flag %q", arg)} + case options.def.ID == "": + options.def.ID = arg + default: + return options, false, execUsageError{"usage: zero hooks add --event --command [flags]"} + } + } + + options.def.ID = strings.TrimSpace(options.def.ID) + if options.def.ID == "" { + return options, false, execUsageError{"usage: zero hooks add --event --command [flags]"} + } + if strings.TrimSpace(string(options.def.Event)) == "" { + return options, false, execUsageError{"zero hooks add requires --event"} + } + if !hooks.IsValidEvent(options.def.Event) { + return options, false, execUsageError{fmt.Sprintf("invalid --event %q; expected one of: beforeTool, afterTool, sessionStart, sessionEnd, specialistStart, specialistStop", options.def.Event)} + } + if strings.TrimSpace(options.def.Command) == "" { + return options, false, execUsageError{"zero hooks add requires --command"} + } + if options.def.Args == nil { + options.def.Args = []string{} + } + return options, false, nil +} + +func writeHooksAddHelp(w io.Writer) error { + _, err := fmt.Fprint(w, `Usage: + zero hooks add --event --command [flags] + +Events: + beforeTool, afterTool, sessionStart, sessionEnd, specialistStart, specialistStop + +Flags: + --event Hook event (required) + --command Command to run (required) + --name Human-readable hook name + --description Hook description + --matcher Tool matcher (beforeTool/afterTool only) + --arg Command argument (repeatable) + --user Write to user config instead of the project + --json Print command result as JSON + -h, --help Show this help + +New hooks are enabled; use "zero hooks disable " to turn one off. +`) + return err +} + +func writeHooksTargetHelp(w io.Writer, command string) error { + _, err := fmt.Fprintf(w, `Usage: + zero hooks %s [flags] + +Flags: + --user Target user config instead of the project + --json Print command result as JSON + -h, --help Show this help +`, command) + return err +} diff --git a/internal/cli/hooks_manage_test.go b/internal/cli/hooks_manage_test.go new file mode 100644 index 000000000..2fb08489c --- /dev/null +++ b/internal/cli/hooks_manage_test.go @@ -0,0 +1,147 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/hooks" +) + +func hooksManageDeps(cwd string) appDeps { + return appDeps{getwd: func() (string, error) { return cwd, nil }} +} + +func projectHooks(t *testing.T, cwd string) hooks.Config { + t.Helper() + paths, err := hooks.ResolvePaths(hooks.ResolvePathOptions{Cwd: cwd}) + if err != nil { + t.Fatalf("ResolvePaths: %v", err) + } + store, err := hooks.NewConfigStore(hooks.StoreOptions{ConfigPath: paths.ProjectConfigPath}) + if err != nil { + t.Fatalf("NewConfigStore: %v", err) + } + config, err := store.List() + if err != nil { + t.Fatalf("List: %v", err) + } + return config +} + +func TestRunHooksAddPersistsToProjectConfig(t *testing.T) { + cwd := t.TempDir() + var stdout, stderr bytes.Buffer + code := runHooksAdd([]string{"zero.preflight", "--event", "beforeTool", "--matcher", "bash", "--command", "sh", "--arg", "-c", "--arg", "echo hi"}, &stdout, &stderr, hooksManageDeps(cwd)) + if code != exitSuccess { + t.Fatalf("exit = %d, want %d; stderr=%q", code, exitSuccess, stderr.String()) + } + config := projectHooks(t, cwd) + if len(config.Hooks) != 1 { + t.Fatalf("hooks = %d, want 1", len(config.Hooks)) + } + hook := config.Hooks[0] + if hook.ID != "zero.preflight" || hook.Event != hooks.EventBeforeTool || hook.Command != "sh" { + t.Fatalf("unexpected hook: %+v", hook) + } + if !hook.Enabled { + t.Fatalf("new hook should be enabled: %+v", hook) + } + if strings.Join(hook.Args, " ") != "-c echo hi" { + t.Fatalf("args = %v, want [-c, echo hi]", hook.Args) + } +} + +func TestRunHooksAddRejectsMissingRequiredFlags(t *testing.T) { + cwd := t.TempDir() + for _, tc := range []struct { + name string + args []string + }{ + {"no event", []string{"h1", "--command", "sh"}}, + {"no command", []string{"h1", "--event", "beforeTool"}}, + {"no id", []string{"--event", "beforeTool", "--command", "sh"}}, + } { + t.Run(tc.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := runHooksAdd(tc.args, &stdout, &stderr, hooksManageDeps(cwd)); code == exitSuccess { + t.Fatalf("expected non-success exit for %q", tc.name) + } + if len(projectHooks(t, cwd).Hooks) != 0 { + t.Fatalf("no hook should be written on a usage error") + } + }) + } +} + +func TestRunHooksAddRejectsUnknownEvent(t *testing.T) { + cwd := t.TempDir() + var stdout, stderr bytes.Buffer + if code := runHooksAdd([]string{"h1", "--event", "bogusEvent", "--command", "sh"}, &stdout, &stderr, hooksManageDeps(cwd)); code == exitSuccess { + t.Fatalf("expected non-success exit for an unknown event; stderr=%q", stderr.String()) + } + if len(projectHooks(t, cwd).Hooks) != 0 { + t.Fatalf("no hook should be written when the event is invalid") + } +} + +func TestRunHooksAddJSONRedactsSecretArgs(t *testing.T) { + cwd := t.TempDir() + secret := "sk-proj-" + strings.Repeat("z", 24) + var stdout, stderr bytes.Buffer + code := runHooksAdd([]string{"h1", "--event", "beforeTool", "--command", "sh", "--arg", "-c", "--arg", "echo " + secret, "--json"}, &stdout, &stderr, hooksManageDeps(cwd)) + if code != exitSuccess { + t.Fatalf("exit = %d, want %d; stderr=%q", code, exitSuccess, stderr.String()) + } + if strings.Contains(stdout.String(), secret) || strings.Contains(stdout.String(), "sk-proj-") { + t.Fatalf("JSON output must redact secret args, got %q", stdout.String()) + } + if !strings.Contains(stdout.String(), "[REDACTED]") { + t.Fatalf("expected a redaction marker in JSON output, got %q", stdout.String()) + } +} + +func TestRunHooksToggleAndRemove(t *testing.T) { + cwd := t.TempDir() + deps := hooksManageDeps(cwd) + var out, errBuf bytes.Buffer + if code := runHooksAdd([]string{"h1", "--event", "afterTool", "--command", "sh"}, &out, &errBuf, deps); code != exitSuccess { + t.Fatalf("add exit = %d; stderr=%q", code, errBuf.String()) + } + + // disable + out.Reset() + errBuf.Reset() + if code := runHooksToggle([]string{"h1"}, &out, &errBuf, deps, true); code != exitSuccess { + t.Fatalf("disable exit = %d; stderr=%q", code, errBuf.String()) + } + if projectHooks(t, cwd).Hooks[0].Enabled { + t.Fatalf("hook should be disabled after disable") + } + + // enable + if code := runHooksToggle([]string{"h1"}, &out, &errBuf, deps, false); code != exitSuccess { + t.Fatalf("enable exit = %d; stderr=%q", code, errBuf.String()) + } + if !projectHooks(t, cwd).Hooks[0].Enabled { + t.Fatalf("hook should be enabled after enable") + } + + // toggle of an unknown id is a usage error + if code := runHooksToggle([]string{"missing"}, &out, &errBuf, deps, true); code == exitSuccess { + t.Fatalf("disabling an unknown hook should fail") + } + + // remove + if code := runHooksRemove([]string{"h1"}, &out, &errBuf, deps); code != exitSuccess { + t.Fatalf("remove exit = %d; stderr=%q", code, errBuf.String()) + } + if len(projectHooks(t, cwd).Hooks) != 0 { + t.Fatalf("hook should be gone after remove") + } + + // removing again is a no-op success + if code := runHooksRemove([]string{"h1"}, &out, &errBuf, deps); code != exitSuccess { + t.Fatalf("removing a missing hook should succeed as a no-op, got %d", code) + } +} diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 752c4d6fd..732e22c78 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -802,18 +802,31 @@ func requiredID(obj map[string]any, field string) (string, error) { return value, nil } +// KnownEvents returns the hook events Zero recognizes, in dispatch order. +func KnownEvents() []Event { + return []Event{EventBeforeTool, EventAfterTool, EventSessionStart, EventSessionEnd, EventSpecialistStart, EventSpecialistStop} +} + +// IsValidEvent reports whether event is one Zero recognizes. +func IsValidEvent(event Event) bool { + for _, known := range KnownEvents() { + if event == known { + return true + } + } + return false +} + func parseEvent(raw any, field string) (Event, error) { text, ok := raw.(string) if !ok || strings.TrimSpace(text) == "" { return "", manifestError{fieldPath: field, message: "Expected a hook event."} } event := Event(strings.TrimSpace(text)) - switch event { - case EventBeforeTool, EventAfterTool, EventSessionStart, EventSessionEnd, EventSpecialistStart, EventSpecialistStop: - return event, nil - default: + if !IsValidEvent(event) { return "", manifestError{fieldPath: field, message: "Expected beforeTool, afterTool, sessionStart, sessionEnd, specialistStart, or specialistStop."} } + return event, nil } func optionalArray(raw any, field string) ([]any, error) {