diff --git a/.abcd/config/identity.json b/.abcd/config/identity.json new file mode 100644 index 00000000..e5469778 --- /dev/null +++ b/.abcd/config/identity.json @@ -0,0 +1,4 @@ +{ + "name": "REPPL", + "email": "77722411+REPPL@users.noreply.github.com" +} diff --git a/.abcd/work/issues/open/iss-63-identity-gate-followups.md b/.abcd/work/issues/open/iss-63-identity-gate-followups.md new file mode 100644 index 00000000..53f41439 --- /dev/null +++ b/.abcd/work/issues/open/iss-63-identity-gate-followups.md @@ -0,0 +1,11 @@ +--- +schema_version: 1 +id: "iss-63" +slug: "identity-gate-followups" +severity: "minor" +category: "future-work-seed" +source: "agent-finding" +found_during: "iss-62-security-review" +--- + +iss-62 identity-gate follow-ups from security review (advisory, non-blocking): (1) the pre-commit shell guard mis-parses a pinned name containing an escaped double-quote (sed truncates it), blocking even a correctly-configured identity — fail-closed but a usability bug; WritePin also emits that escaped form. Consider delegating to 'abcd ahoy identity-check' when on PATH, or a more robust JSON parse. (2) A programmatic caller passing ApprovedCategories{ConfigChange:true} (without --yes) still auto-pins the current identity; deliberate per-category approval, flagged as a conscious choice. \ No newline at end of file diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3389df54..da4424dd 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -16,6 +16,38 @@ set -euo pipefail cd "$(git rev-parse --show-toplevel)" +# --- iss-62 identity gate --------------------------------------------------- +# Refuse a commit whose author identity diverges from the committed pin +# (.abcd/config/identity.json). Self-contained (no binary dependency) so the +# guard holds even before abcd is built or on PATH, and can't be broken by a +# stale abcd. Runs FIRST — the name-guard below early-exits when no banlist is +# present, which must not skip this check. No-op ONLY when the repo has no pin. +# +# FAIL CLOSED: a pin that is present but that this shell cannot read (empty +# value, or non-lowercase / non-canonical JSON keys the sed does not match) +# BLOCKS the commit rather than falling through. The Go check +# (`abcd ahoy identity-check`) enforces such pins directly; this shell guard is +# deliberately stricter — it never permits an unverified identity. Keep the two +# in the same fail-closed direction (see identity_test.go / the shell hook test). +identity_pin=".abcd/config/identity.json" +if [ -f "$identity_pin" ]; then + want_name=$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$identity_pin" | head -n1) + want_email=$(sed -n 's/.*"email"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$identity_pin" | head -n1) + if [ -z "$want_name" ] || [ -z "$want_email" ]; then + echo "pre-commit: BLOCKED — $identity_pin is present but its name/email could not be read (fail closed)." >&2 + echo " check the pin's JSON (lowercase \"name\"/\"email\", both non-empty), or run: abcd ahoy identity-check" >&2 + exit 1 + fi + have_name=$(git config user.name || true) + have_email=$(git config user.email || true) + if [ "$have_name" != "$want_name" ] || [ "$have_email" != "$want_email" ]; then + echo "pre-commit: BLOCKED — git identity \"$have_name <$have_email>\" does not match the pinned \"$want_name <$want_email>\" ($identity_pin)." >&2 + echo " fix: git config user.name \"$want_name\" && git config user.email \"$want_email\"" >&2 + exit 1 + fi +fi +# --- end iss-62 identity gate ----------------------------------------------- + banlist=".abcd/.work.local/private-names.txt" # Refresh the generated block from the user-level sources corpus when present diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f90abda..502047f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ called out in a **Breaking** section. ### Added +- A managed-repo **git-identity gate** (iss-62): a repo can pin its expected + commit identity in `.abcd/config/identity.json`, and every commit is checked + against it. `ahoy doctor` reports a divergence (a repo-local override that + differs from the pin, or an unset identity) or an un-pinned repo; `ahoy + install` adopts the gate by pinning the current git identity; `ahoy + identity-check` exits non-zero on a mismatch; and the `pre-commit` hook + fail-closes so a stray identity (e.g. a sandbox default) is caught at commit + time rather than discovered later. A repo with no pin is unaffected. - A `context_status_free` record-lint rule: the shared orientation file (`rules.context_status_free.target`, by convention `.abcd/work/CONTEXT.md`) must carry no phase/status claims — status is read live from the CLI and diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index d88c2257..f8254167 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -7,6 +7,8 @@ import ( "sort" "strings" "time" + + "github.com/REPPL/abcd-cli/internal/core/identity" ) // Install runs detect + apply over the approved categories. It is idempotent: @@ -62,6 +64,7 @@ func Install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) overrides: opts.ValueOverrides, prompter: p, gapPresent: gapIDSet(det.Gaps), + autoYes: opts.Yes, } // Ordered apply steps. @@ -74,6 +77,7 @@ func Install(cwd string, opts InstallOptions, p Prompter) (InstallResult, error) ac.stepSymlink() ac.stepRules() ac.stepVersionStamp() + ac.stepIdentityPin() // Re-detect to compute what remains. final, err := Detect(abs) @@ -104,10 +108,34 @@ type applyCtx struct { prompter Prompter gapPresent map[string]bool writes []string + autoYes bool // --yes: every category auto-approved without interaction } func (a *applyCtx) note(path string) { a.writes = append(a.writes, path) } +// stepIdentityPin adopts the iss-62 identity gate for an un-pinned repo: it +// writes .abcd/config/identity.json from the current git author identity (the +// proposal), gated on ConfigChange approval (the confirmation). A mismatch is +// never auto-resolved — abcd must not silently change the pin or the user's git +// identity — so it stays a guided manual fix. +// +// It does NOT auto-adopt under --yes: pinning captures whatever git identity is +// currently set, so a non-interactive run could pin a wrong/sandbox identity as +// canonical (the very value the gate exists to reject). Under --yes the +// un-pinned gap simply remains, to be adopted with an interactive confirmation. +func (a *applyCtx) stepIdentityPin() { + if a.autoYes || !a.approved[ConfigChange] || !a.has("git_identity.unpinned") { + return + } + eff, err := identity.EffectiveIdentity(a.cwd) + if err != nil || eff.Name == "" || eff.Email == "" { + return + } + if err := identity.WritePin(a.cwd, identity.Pin{Name: eff.Name, Email: eff.Email}); err == nil { + a.note(identity.PinRelPath) + } +} + func (a *applyCtx) has(id string) bool { return a.gapPresent[id] } // stepDependencies re-probes PATH; surfaces the fix hint but never auto-runs a diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index 954258c3..7afd92ca 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -1,9 +1,12 @@ package ahoy import ( + "os" "os/exec" "path/filepath" "sort" + + "github.com/REPPL/abcd-cli/internal/core/identity" ) // Enumerations for config-value validation. @@ -56,6 +59,7 @@ func Detect(cwd string) (DetectionResult, error) { gaps = append(gaps, detectDependencies()...) gaps = append(gaps, detectSkeleton(abs)...) gaps = append(gaps, detectIdentity(identity, idx)...) + gaps = append(gaps, detectGitIdentity(abs)...) gaps = append(gaps, detectHistoryStore(identity.RootSHA)...) gaps = append(gaps, detectConfigValues(abs)...) gaps = append(gaps, detectMarkerDrift(abs)...) @@ -186,6 +190,58 @@ func detectIdentity(id RepoIdentity, idx *historyIndex) []Gap { return gaps } +// detectGitIdentity compares the git author identity a commit would use against +// the committed .abcd/config/identity.json pin (iss-62). A mismatch or an unset +// identity is a required, resolvable gap; an un-pinned repo gets an advisory gap +// (adopt the gate); a match yields nothing. +func detectGitIdentity(cwd string) []Gap { + res, err := identity.Check(cwd) + if err != nil { + // A present-but-unreadable pin is an adopted-but-broken gate (required); + // an error with no pin is a git/environment issue (advisory). + _, statErr := os.Stat(filepath.Join(cwd, identity.PinRelPath)) + pinPresent := statErr == nil + return []Gap{{ + ID: "git_identity.uncheckable", Category: ConfigChange, Scope: "repo", + Title: "git identity could not be checked", + Detail: err.Error(), + FixHint: "fix the pin JSON in " + identity.PinRelPath + " (both name and email), or ensure git is available", + Required: pinPresent, + Resolvable: false, + }} + } + switch res.Status { + case identity.StatusMismatch: + return []Gap{{ + ID: "git_identity.mismatch", Category: ConfigChange, Scope: "repo", + Title: "git commit identity does not match the pin", + Detail: res.Reason, + FixHint: "set this repo's git user.name/user.email to match the pin in " + identity.PinRelPath + " (or update the pin if the identity changed)", + Required: true, + Resolvable: true, + }} + case identity.StatusUnset: + return []Gap{{ + ID: "git_identity.unset", Category: ConfigChange, Scope: "repo", + Title: "git author identity is not configured", + Detail: res.Reason, + FixHint: "set git user.name/user.email to the pinned identity in " + identity.PinRelPath, + Required: true, + Resolvable: true, + }} + case identity.StatusNoPin: + return []Gap{{ + ID: "git_identity.unpinned", Category: ConfigChange, Scope: "repo", + Title: "no git identity pin", + Detail: res.Reason, + FixHint: "ahoy install can pin the current git identity to " + identity.PinRelPath, + Required: false, + Resolvable: true, + }} + } + return nil +} + func detectHistoryStore(rootSHA string) []Gap { var gaps []Gap root, err := historyRoot() diff --git a/internal/core/ahoy/identity_gap_test.go b/internal/core/ahoy/identity_gap_test.go new file mode 100644 index 00000000..867a587d --- /dev/null +++ b/internal/core/ahoy/identity_gap_test.go @@ -0,0 +1,114 @@ +package ahoy + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/REPPL/abcd-cli/internal/core/identity" +) + +func idGitRepo(t *testing.T, name, email string) string { + t.Helper() + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) + dir := t.TempDir() + idMustGit(t, dir, "init") + if name != "" { + idMustGit(t, dir, "config", "user.name", name) + } + if email != "" { + idMustGit(t, dir, "config", "user.email", email) + } + return dir +} + +func idMustGit(t *testing.T, dir string, args ...string) { + t.Helper() + if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +func idWritePin(t *testing.T, root, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(root, ".abcd", "config"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".abcd", "config", "identity.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestDetectGitIdentity_Mismatch(t *testing.T) { + dir := idGitRepo(t, "Test User", "test@example.com") + idWritePin(t, dir, `{"name":"Alex Reppel","email":"alex@example.com"}`) + gaps := detectGitIdentity(dir) + if len(gaps) != 1 || gaps[0].ID != "git_identity.mismatch" || !gaps[0].Required { + t.Fatalf("want one required git_identity.mismatch gap, got %+v", gaps) + } +} + +func TestDetectGitIdentity_Match(t *testing.T) { + dir := idGitRepo(t, "Alex Reppel", "alex@example.com") + idWritePin(t, dir, `{"name":"Alex Reppel","email":"alex@example.com"}`) + if gaps := detectGitIdentity(dir); len(gaps) != 0 { + t.Fatalf("want no gap on match, got %+v", gaps) + } +} + +func TestDetectGitIdentity_Unpinned(t *testing.T) { + dir := idGitRepo(t, "Alex Reppel", "alex@example.com") + gaps := detectGitIdentity(dir) + if len(gaps) != 1 || gaps[0].ID != "git_identity.unpinned" || gaps[0].Required { + t.Fatalf("want one advisory git_identity.unpinned gap, got %+v", gaps) + } +} + +func TestStepIdentityPin_WritesFromCurrentIdentity(t *testing.T) { + dir := idGitRepo(t, "Alex Reppel", "alex@example.com") + a := &applyCtx{ + cwd: dir, + approved: map[GapCategory]bool{ConfigChange: true}, + gapPresent: map[string]bool{"git_identity.unpinned": true}, + } + a.stepIdentityPin() + got, ok, err := identity.LoadPin(dir) + if err != nil || !ok { + t.Fatalf("pin not written: ok=%v err=%v", ok, err) + } + if got.Name != "Alex Reppel" || got.Email != "alex@example.com" { + t.Fatalf("wrong pin written: %+v", got) + } + if len(a.writes) != 1 || a.writes[0] != identity.PinRelPath { + t.Fatalf("expected one noted write of the pin, got %v", a.writes) + } +} + +func TestStepIdentityPin_SkipsUnderYes(t *testing.T) { + dir := idGitRepo(t, "Test User", "test@example.com") + a := &applyCtx{ + cwd: dir, + approved: map[GapCategory]bool{ConfigChange: true}, + gapPresent: map[string]bool{"git_identity.unpinned": true}, + autoYes: true, + } + a.stepIdentityPin() + if _, ok, _ := identity.LoadPin(dir); ok { + t.Fatal("--yes must not silently pin the current (possibly sandbox) identity") + } +} + +func TestStepIdentityPin_NeverAutoResolvesMismatch(t *testing.T) { + dir := idGitRepo(t, "Test User", "test@example.com") + a := &applyCtx{ + cwd: dir, + approved: map[GapCategory]bool{ConfigChange: true}, + gapPresent: map[string]bool{"git_identity.mismatch": true}, + } + a.stepIdentityPin() + if _, ok, _ := identity.LoadPin(dir); ok { + t.Fatal("a mismatch must never auto-write a pin") + } +} diff --git a/internal/core/identity/hook_test.go b/internal/core/identity/hook_test.go new file mode 100644 index 00000000..c1ed528c --- /dev/null +++ b/internal/core/identity/hook_test.go @@ -0,0 +1,106 @@ +package identity + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// locateHook finds the committed .githooks/pre-commit by walking up from the +// test's working directory. Skips when not run from a checkout (e.g. a build +// tarball) or when bash/git are unavailable. +func locateHook(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash unavailable") + } + out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + t.Skip("not in a git checkout") + } + hook := filepath.Join(strings.TrimSpace(string(out)), ".githooks", "pre-commit") + if _, err := os.Stat(hook); err != nil { + t.Skipf("hook not found: %v", err) + } + return hook +} + +func hookGit(t *testing.T, dir string, env []string, args ...string) error { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = env + out, err := cmd.CombinedOutput() + if err != nil && !strings.Contains(strings.Join(args, " "), "commit") { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return err +} + +// TestPreCommitHook_IdentityGate exercises the committed shell hook end to end, +// pinning down the fail-closed contract: a pin the shell cannot read must BLOCK, +// never fall through (the F1 regression this test guards). +func TestPreCommitHook_IdentityGate(t *testing.T) { + hook := locateHook(t) + // Isolate from the machine's git global/system config and ~/.abcd corpus so + // the hook only sees the temp repo. + home := t.TempDir() + env := append(os.Environ(), + "GIT_CONFIG_GLOBAL="+os.DevNull, + "GIT_CONFIG_SYSTEM="+os.DevNull, + "HOME="+home, + ) + + cases := []struct { + name string + pin string // "" => no identity.json + gitName string + gitEmail string + wantBlock bool + }{ + {"no pin passes", "", "Whoever", "who@ever.com", false}, + {"match passes", `{"name":"Alex","email":"a@b.com"}`, "Alex", "a@b.com", false}, + {"mismatch blocks", `{"name":"Alex","email":"a@b.com"}`, "Test User", "test@example.com", true}, + {"pretty-printed match passes", "{\n \"name\": \"Alex\",\n \"email\": \"a@b.com\"\n}\n", "Alex", "a@b.com", false}, + {"non-canonical key blocks (fail closed)", `{"NAME":"Alex","email":"a@b.com"}`, "Alex", "a@b.com", true}, + {"empty value blocks (fail closed)", `{"name":"","email":""}`, "Alex", "a@b.com", true}, + {"malformed blocks (fail closed)", `{garbage`, "Alex", "a@b.com", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + hookGit(t, dir, env, "init") + hookGit(t, dir, env, "config", "user.name", tc.gitName) + hookGit(t, dir, env, "config", "user.email", tc.gitEmail) + if tc.pin != "" { + if err := os.MkdirAll(filepath.Join(dir, ".abcd", "config"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".abcd", "config", "identity.json"), []byte(tc.pin), 0o644); err != nil { + t.Fatal(err) + } + } + hooksDir := filepath.Join(dir, ".git", "hooks") + if err := os.MkdirAll(hooksDir, 0o755); err != nil { + t.Fatal(err) + } + src, err := os.ReadFile(hook) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + hookGit(t, dir, env, "add", "-A") + err = hookGit(t, dir, env, "commit", "-m", "t") + blocked := err != nil + if blocked != tc.wantBlock { + t.Fatalf("blocked=%v, want %v", blocked, tc.wantBlock) + } + }) + } +} diff --git a/internal/core/identity/identity.go b/internal/core/identity/identity.go new file mode 100644 index 00000000..86d9cbfc --- /dev/null +++ b/internal/core/identity/identity.go @@ -0,0 +1,180 @@ +// Package identity checks that the git author identity a commit would use in a +// managed repo matches the identity pinned in .abcd/config/identity.json. +// +// It is the single source of truth for the iss-62 managed-repo identity gate: +// `ahoy doctor` surfaces a divergence as a detection gap, and the installed +// pre-commit hook calls Check to fail closed before a mis-attributed commit can +// land — so a stray repo-local override (e.g. a sandbox "Test User") is caught +// up front rather than discovered later. +package identity + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// PinRelPath is the committed identity pin, relative to the repo root. +const PinRelPath = ".abcd/config/identity.json" + +// Pin is the expected commit identity, committed so every checkout enforces the +// same value regardless of local git config. +type Pin struct { + Name string `json:"name"` + Email string `json:"email"` +} + +// Effective is the identity git would actually stamp on a commit in the repo, +// resolved through git's normal local > global > system layering. +type Effective struct { + Name string + Email string +} + +// Status is the outcome of comparing the effective identity to the pin. +type Status int + +const ( + // StatusOK: a pin exists and the effective identity matches it. + StatusOK Status = iota + // StatusNoPin: no identity.json — the repo has not opted into the gate. + StatusNoPin + // StatusMismatch: a pin exists and the effective identity differs. + StatusMismatch + // StatusUnset: a pin exists but git has no author identity configured. + StatusUnset +) + +func (s Status) String() string { + switch s { + case StatusOK: + return "ok" + case StatusNoPin: + return "no-pin" + case StatusMismatch: + return "mismatch" + case StatusUnset: + return "unset" + default: + return "unknown" + } +} + +// Result carries the comparison outcome and both identities for reporting. +type Result struct { + Status Status + Pin Pin + Effective Effective + Reason string +} + +// Blocks reports whether a pre-commit hook should refuse the commit. A mismatch +// or an unset identity blocks; a match, or an un-pinned (opted-out) repo, does +// not — an absent pin must never break commits in a repo that has not adopted +// the gate. +func (r Result) Blocks() bool { + return r.Status == StatusMismatch || r.Status == StatusUnset +} + +// LoadPin reads .abcd/config/identity.json. It returns (pin, true, nil) when the +// pin is present and well formed, (Pin{}, false, nil) when the file is absent, +// and an error when it is malformed or missing a field — validating this +// external input rather than trusting it. +func LoadPin(root string) (Pin, bool, error) { + path := filepath.Join(root, PinRelPath) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return Pin{}, false, nil + } + return Pin{}, false, fmt.Errorf("reading %s: %w", PinRelPath, err) + } + var p Pin + if err := json.Unmarshal(data, &p); err != nil { + return Pin{}, false, fmt.Errorf("malformed %s: %w", PinRelPath, err) + } + p.Name = strings.TrimSpace(p.Name) + p.Email = strings.TrimSpace(p.Email) + if p.Name == "" || p.Email == "" { + return Pin{}, false, fmt.Errorf("%s must set both name and email", PinRelPath) + } + return p, true, nil +} + +// WritePin writes the pin to .abcd/config/identity.json (creating the config +// directory), pretty-printed with a trailing newline. It is how a repo adopts +// the identity gate. Both fields are required. +func WritePin(root string, p Pin) error { + p.Name = strings.TrimSpace(p.Name) + p.Email = strings.TrimSpace(p.Email) + if p.Name == "" || p.Email == "" { + return fmt.Errorf("identity pin requires both name and email") + } + path := filepath.Join(root, PinRelPath) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o644) +} + +// EffectiveIdentity returns the author identity git would use for a commit in +// root, via `git config` (which honours the local > global > system layering). +// Unset name or email yields an empty field, not an error. +func EffectiveIdentity(root string) (Effective, error) { + name, err := gitConfig(root, "user.name") + if err != nil { + return Effective{}, err + } + email, err := gitConfig(root, "user.email") + if err != nil { + return Effective{}, err + } + return Effective{Name: name, Email: email}, nil +} + +// gitConfig returns the trimmed value of a git config key, or "" when the key is +// unset. Git exits 1 for an unset key; that is not an error here. Any other +// failure (git absent, not a repo) is returned. +func gitConfig(root, key string) (string, error) { + cmd := exec.Command("git", "-C", root, "config", "--get", key) + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { + return "", nil // unset key + } + return "", fmt.Errorf("git config %s: %w", key, err) + } + return strings.TrimSpace(string(out)), nil +} + +// Check resolves the effective identity, loads the pin, and compares them. +func Check(root string) (Result, error) { + pin, pinned, err := LoadPin(root) + if err != nil { + return Result{}, err + } + eff, err := EffectiveIdentity(root) + if err != nil { + return Result{}, err + } + if !pinned { + return Result{Status: StatusNoPin, Effective: eff, Reason: "no " + PinRelPath + "; repo has not adopted the identity gate"}, nil + } + if eff.Name == "" || eff.Email == "" { + return Result{Status: StatusUnset, Pin: pin, Effective: eff, Reason: "git author identity is not configured (user.name/user.email)"}, nil + } + if eff.Name != pin.Name || eff.Email != pin.Email { + return Result{ + Status: StatusMismatch, Pin: pin, Effective: eff, + Reason: fmt.Sprintf("commit identity %q <%s> does not match the pin %q <%s>", eff.Name, eff.Email, pin.Name, pin.Email), + }, nil + } + return Result{Status: StatusOK, Pin: pin, Effective: eff}, nil +} diff --git a/internal/core/identity/identity_test.go b/internal/core/identity/identity_test.go new file mode 100644 index 00000000..8ff2a45c --- /dev/null +++ b/internal/core/identity/identity_test.go @@ -0,0 +1,160 @@ +package identity + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// isolate points git at empty global/system config so the test only sees the +// temp repo's local config, making identity resolution hermetic regardless of +// the machine's real git identity. +func isolate(t *testing.T) { + t.Helper() + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) +} + +func gitRepo(t *testing.T, name, email string) string { + t.Helper() + dir := t.TempDir() + runGitT(t, dir, "init") + if name != "" { + runGitT(t, dir, "config", "user.name", name) + } + if email != "" { + runGitT(t, dir, "config", "user.email", email) + } + return dir +} + +func runGitT(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +func writePin(t *testing.T, root, body string) { + t.Helper() + dir := filepath.Join(root, ".abcd", "config") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "identity.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestCheck_Match(t *testing.T) { + isolate(t) + dir := gitRepo(t, "Alex Reppel", "alex@example.com") + writePin(t, dir, `{"name":"Alex Reppel","email":"alex@example.com"}`) + res, err := Check(dir) + if err != nil { + t.Fatal(err) + } + if res.Status != StatusOK { + t.Fatalf("want StatusOK, got %v — %s", res.Status, res.Reason) + } +} + +func TestCheck_Mismatch(t *testing.T) { + isolate(t) + dir := gitRepo(t, "Test User", "test@example.com") + writePin(t, dir, `{"name":"Alex Reppel","email":"alex@example.com"}`) + res, err := Check(dir) + if err != nil { + t.Fatal(err) + } + if res.Status != StatusMismatch { + t.Fatalf("want StatusMismatch, got %v — %s", res.Status, res.Reason) + } + if res.Effective.Email != "test@example.com" || res.Pin.Email != "alex@example.com" { + t.Fatalf("result did not carry both identities: %+v", res) + } +} + +func TestCheck_NoPin(t *testing.T) { + isolate(t) + dir := gitRepo(t, "Alex Reppel", "alex@example.com") + // no identity.json written + res, err := Check(dir) + if err != nil { + t.Fatal(err) + } + if res.Status != StatusNoPin { + t.Fatalf("want StatusNoPin, got %v — %s", res.Status, res.Reason) + } +} + +func TestCheck_UnsetIdentity(t *testing.T) { + isolate(t) + dir := gitRepo(t, "", "") // no user.name/email anywhere + writePin(t, dir, `{"name":"Alex Reppel","email":"alex@example.com"}`) + res, err := Check(dir) + if err != nil { + t.Fatal(err) + } + if res.Status != StatusUnset { + t.Fatalf("want StatusUnset, got %v — %s", res.Status, res.Reason) + } +} + +func TestLoadPin_Malformed(t *testing.T) { + isolate(t) + dir := t.TempDir() + writePin(t, dir, `{"name": "no closing quote`) + if _, _, err := LoadPin(dir); err == nil { + t.Fatal("want error on malformed identity.json, got nil") + } +} + +func TestLoadPin_MissingFields(t *testing.T) { + isolate(t) + dir := t.TempDir() + writePin(t, dir, `{"name":"Alex Reppel"}`) // no email + if _, _, err := LoadPin(dir); err == nil { + t.Fatal("want error when email is empty, got nil") + } +} + +func TestWritePin_RoundTrip(t *testing.T) { + dir := t.TempDir() + want := Pin{Name: "Alex Reppel", Email: "alex@example.com"} + if err := WritePin(dir, want); err != nil { + t.Fatal(err) + } + got, ok, err := LoadPin(dir) + if err != nil || !ok { + t.Fatalf("LoadPin after WritePin: ok=%v err=%v", ok, err) + } + if got != want { + t.Fatalf("round-trip mismatch: got %+v want %+v", got, want) + } +} + +func TestWritePin_RequiresBothFields(t *testing.T) { + dir := t.TempDir() + if err := WritePin(dir, Pin{Name: "Alex"}); err == nil { + t.Fatal("want error when email is missing") + } +} + +// Blocks reports whether a pre-commit hook should refuse the commit: a mismatch +// or an unset identity blocks; OK and NoPin (opted-out) do not. +func TestBlocks(t *testing.T) { + cases := map[Status]bool{ + StatusOK: false, + StatusNoPin: false, + StatusMismatch: true, + StatusUnset: true, + } + for s, want := range cases { + if got := (Result{Status: s}).Blocks(); got != want { + t.Errorf("Status %v: Blocks()=%v, want %v", s, got, want) + } + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index edfce2a2..2e6dc87f 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -19,6 +19,7 @@ import ( "github.com/REPPL/abcd-cli/internal/core/ahoy" "github.com/REPPL/abcd-cli/internal/core/capture" "github.com/REPPL/abcd-cli/internal/core/history" + "github.com/REPPL/abcd-cli/internal/core/identity" "github.com/REPPL/abcd-cli/internal/core/launch" "github.com/REPPL/abcd-cli/internal/core/lint" "github.com/REPPL/abcd-cli/internal/core/memory" @@ -341,6 +342,32 @@ func newAhoyCommand(asJSON *bool) *cobra.Command { }, }) + // identity-check — the iss-62 gate's canonical, testable entrypoint. Exits + // non-zero when the commit identity diverges from the committed pin, so a + // pre-commit hook (or CI) can fail closed. A match, or an un-pinned repo, + // exits zero. + ahoyCmd.AddCommand(&cobra.Command{ + Use: "identity-check", + Short: "Exit non-zero if the git commit identity does not match .abcd/config/identity.json", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + res, err := identity.Check(cwd) + if err != nil { + return err + } + if res.Blocks() { + return fmt.Errorf("%s\n fix: git config user.name %q && git config user.email %q", + res.Reason, res.Pin.Name, res.Pin.Email) + } + fmt.Fprintf(cmd.OutOrStdout(), "identity ok (%s)\n", res.Status) + return nil + }, + }) + return ahoyCmd }