Files
zero/internal/cli/hooks_manage_test.go
KRATOS b1ab01f53f Hooks management command + build & supply-chain hardening (#193)
* 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.
2026-06-14 11:56:29 +05:30

148 lines
4.9 KiB
Go

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