mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* Bump toolchain to go1.26.4 to clear reachable stdlib CVEs go.mod pinned toolchain go1.24.13, whose bundled standard library has 9 govulncheck-reachable CVEs (crypto/tls KeyUpdate DoS, net/http2 loop, crypto/x509 x3, net, net/url, os, net/textproto). CI and release build via go-version-file: go.mod, so shipped binaries inherited them. Bumping the toolchain directive to go1.26.4 clears all nine — govulncheck ./... now reports 0 reachable (minimum that clears them is go1.25.11; go1.26.4 matches the team's local Go). The go directive stays at 1.24.2 (minimum language version). Verified: go build/vet/gofmt clean, full go test ./... green. * Add govulncheck CI gate and SHA-pin GitHub Actions - ci.yml: new 'security' job runs govulncheck as a hard gate (fails on a reachable vulnerability; passes now that the toolchain is bumped) plus an advisory deadcode step that surfaces dormant code without blocking. - Pin every GitHub Action to a full commit SHA (checkout, upload-artifact, github-script) with a version comment, across all four workflows — previously only setup-go was pinned, leaving the mutable v4/v7 tags as a supply-chain risk. * Add hooks management command (add/remove/enable/disable) Wires the previously-dormant hooks.ConfigStore (10 unreachable funcs -> 0) into the CLI: zero hooks add <id> --event <event> --command <cmd> [--name --description --matcher --arg --user --json] zero hooks remove|enable|disable <id> [--user --json] Writes the project hook config by default (<cwd>/.zero/hooks.json) or the user config with --user, reusing the store's locking + atomic writes + validation (normalizeDefinition). New hooks are enabled; state is managed via enable/disable. JSON output is secret-scrubbed via redaction. Mirrors the existing 'zero mcp add/remove/enable/disable' shape. Covered by add/remove/toggle round-trip, validation, unknown-event, and JSON-redaction tests. * Fix govulncheck CI job: pin GOTOOLCHAIN to the go.mod toolchain go run govulncheck@latest selected the toolchain from govulncheck's own go.mod (downgrading to go1.25.11), which then could not load our go1.26-requiring packages (fips140only_go1.26.go), failing the security job. Resolve GOTOOLCHAIN from go.mod's toolchain line so govulncheck and deadcode both run under go1.26.4. * Address review: harden CI checkout, pin tool versions, validate hook event CodeRabbit findings on #193: - ci.yml: persist-credentials: false on all checkout steps (smoke/performance/security) — these jobs run repo code and never push, so don't keep the token in git config. - ci.yml: pin [email protected] and [email protected].0 instead of @latest, so the security gate is deterministic and not subject to supply-chain drift. - hooks: add exported IsValidEvent/KnownEvents (single source); parseEvent reuses it, and 'zero hooks add' now rejects an invalid --event with a usage error at parse time instead of falling through to the Upsert app-error crash path.
344 lines
10 KiB
Go
344 lines
10 KiB
Go
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 <cwd>/.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 <id> [--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 <id> [--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 <id> --event <event> --command <cmd> [flags]"}
|
|
}
|
|
}
|
|
|
|
options.def.ID = strings.TrimSpace(options.def.ID)
|
|
if options.def.ID == "" {
|
|
return options, false, execUsageError{"usage: zero hooks add <id> --event <event> --command <cmd> [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 <id> --event <event> --command <cmd> [flags]
|
|
|
|
Events:
|
|
beforeTool, afterTool, sessionStart, sessionEnd, specialistStart, specialistStop
|
|
|
|
Flags:
|
|
--event <event> Hook event (required)
|
|
--command <cmd> Command to run (required)
|
|
--name <name> Human-readable hook name
|
|
--description <text> Hook description
|
|
--matcher <pattern> Tool matcher (beforeTool/afterTool only)
|
|
--arg <value> 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 <id>" to turn one off.
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeHooksTargetHelp(w io.Writer, command string) error {
|
|
_, err := fmt.Fprintf(w, `Usage:
|
|
zero hooks %s <id> [flags]
|
|
|
|
Flags:
|
|
--user Target user config instead of the project
|
|
--json Print command result as JSON
|
|
-h, --help Show this help
|
|
`, command)
|
|
return err
|
|
}
|