From acd41057f4db32b7c9805791e19550842c3ef485 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:34:04 +0100 Subject: [PATCH 1/6] feat: add identity-check core for the managed-repo identity gate (iss-62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single source of truth for the git-identity gate: LoadPin reads the committed .abcd/config/identity.json pin, EffectiveIdentity resolves what git would stamp on a commit (local>global>system), and Check compares them into a Status (ok / no-pin / mismatch / unset). Result.Blocks() is the pre-commit verdict — mismatch or unset blocks; a match or an un-pinned (opted-out) repo does not, so an absent pin never breaks commits. TDD: 8 subtests, hermetic (git isolated from machine global/system config). NOT yet wired — the ahoy doctor gap, the pre-commit hook, and ahoy-install propose-confirm are the next increments. Assisted-by: Claude:claude-opus-4-8 --- internal/core/identity/identity.go | 160 ++++++++++++++++++++++++ internal/core/identity/identity_test.go | 138 ++++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 internal/core/identity/identity.go create mode 100644 internal/core/identity/identity_test.go diff --git a/internal/core/identity/identity.go b/internal/core/identity/identity.go new file mode 100644 index 00000000..4e91e13b --- /dev/null +++ b/internal/core/identity/identity.go @@ -0,0 +1,160 @@ +// 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 +} + +// 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..4ab2a03b --- /dev/null +++ b/internal/core/identity/identity_test.go @@ -0,0 +1,138 @@ +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") + } +} + +// 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) + } + } +} From 86de87b5015c2d86c00ef691e46c5bdf496ed8de Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:39:15 +0100 Subject: [PATCH 2/6] feat: surface git-identity divergence as an ahoy detection gap (iss-62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detectGitIdentity wires identity.Check into the detection pass: a mismatch or unset identity is a required, resolvable config-change gap; an un-pinned repo gets an advisory gap to adopt the gate; a match is silent. Verified end to end — `ahoy doctor` on this (un-pinned) repo now reports git_identity.unpinned. TDD: mismatch/match/unpinned subtests, hermetic. Assisted-by: Claude:claude-opus-4-8 --- internal/core/ahoy/detect.go | 51 +++++++++++++++++++ internal/core/ahoy/identity_gap_test.go | 65 +++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 internal/core/ahoy/identity_gap_test.go diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index 954258c3..53a2de6c 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -4,6 +4,8 @@ import ( "os/exec" "path/filepath" "sort" + + "github.com/REPPL/abcd-cli/internal/core/identity" ) // Enumerations for config-value validation. @@ -56,6 +58,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 +189,54 @@ 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 { + return []Gap{{ + ID: "git_identity.uncheckable", Category: ConfigChange, Scope: "repo", + Title: "git identity could not be checked", + Detail: err.Error(), + FixHint: "ensure git is installed and this is a git repository", + Required: false, + 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 the pin in " + identity.PinRelPath + " (ahoy install proposes it)", + 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..54ae408f --- /dev/null +++ b/internal/core/ahoy/identity_gap_test.go @@ -0,0 +1,65 @@ +package ahoy + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +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) + } +} From 2a8e3762758e8a0ca9679d30191316c1d576de22 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:43:45 +0100 Subject: [PATCH 3/6] feat: pre-commit identity gate + ahoy identity-check command (iss-62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prevention half of the gate. `abcd ahoy identity-check` is the canonical, tested entrypoint: it exits non-zero when the commit identity diverges from .abcd/config/identity.json (mismatch/unset) and zero on a match or an un-pinned repo. The pre-commit hook enforces the same rule self-contained in shell (no binary dependency, so it holds before abcd is built and can't be broken by a stale abcd on PATH), running first so the name-guard's early exits can't skip it. No-op when the repo has no pin. Dogfood verified end to end: with a REPPL pin, a `Test User` commit is blocked and a `REPPL` commit succeeds — so a sandbox re-seed of the identity is caught at commit time, not discovered later. Assisted-by: Claude:claude-opus-4-8 --- .githooks/pre-commit | 22 ++++++++++++++++++++++ internal/surface/cli/cli.go | 27 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3389df54..81c61ddb 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -16,6 +16,28 @@ 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; mirrors `abcd ahoy identity-check`. No-op when the repo has not +# adopted the gate (no pin). Runs FIRST — the name-guard below early-exits when +# no banlist is present, which must not skip this check. +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) + have_name=$(git config user.name || true) + have_email=$(git config user.email || true) + if [ -n "$want_name" ] && [ -n "$want_email" ] \ + && { [ "$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/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 } From 4bb69ea746cc67b986525d9858fa20dcd10ed80f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:47:43 +0100 Subject: [PATCH 4/6] feat: ahoy install adopts the identity gate; pin this repo (iss-62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WritePin writes .abcd/config/identity.json; stepIdentityPin adopts the gate for an un-pinned repo on ConfigChange approval, pinning the current git identity (propose = current identity, confirm = category approval). 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 (FixHint corrected accordingly). Also pins this repo to its REPPL identity, making the gate live here, and adds the CHANGELOG entry. TDD: WritePin round-trip + required-fields; stepIdentityPin writes-from-current and never-auto-resolves-mismatch. make preflight green. Assisted-by: Claude:claude-opus-4-8 --- .abcd/config/identity.json | 4 +++ CHANGELOG.md | 8 ++++++ internal/core/ahoy/apply.go | 21 +++++++++++++++ internal/core/ahoy/detect.go | 2 +- internal/core/ahoy/identity_gap_test.go | 35 +++++++++++++++++++++++++ internal/core/identity/identity.go | 20 ++++++++++++++ internal/core/identity/identity_test.go | 22 ++++++++++++++++ 7 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 .abcd/config/identity.json 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/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..d3ab4622 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: @@ -74,6 +76,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) @@ -108,6 +111,24 @@ type applyCtx struct { 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. +func (a *applyCtx) stepIdentityPin() { + if !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 53a2de6c..9dac4323 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -211,7 +211,7 @@ func detectGitIdentity(cwd string) []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 the pin in " + identity.PinRelPath + " (ahoy install proposes it)", + 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, }} diff --git a/internal/core/ahoy/identity_gap_test.go b/internal/core/ahoy/identity_gap_test.go index 54ae408f..ab510635 100644 --- a/internal/core/ahoy/identity_gap_test.go +++ b/internal/core/ahoy/identity_gap_test.go @@ -5,6 +5,8 @@ import ( "os/exec" "path/filepath" "testing" + + "github.com/REPPL/abcd-cli/internal/core/identity" ) func idGitRepo(t *testing.T, name, email string) string { @@ -63,3 +65,36 @@ func TestDetectGitIdentity_Unpinned(t *testing.T) { 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_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/identity.go b/internal/core/identity/identity.go index 4e91e13b..86d9cbfc 100644 --- a/internal/core/identity/identity.go +++ b/internal/core/identity/identity.go @@ -104,6 +104,26 @@ func LoadPin(root string) (Pin, bool, error) { 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. diff --git a/internal/core/identity/identity_test.go b/internal/core/identity/identity_test.go index 4ab2a03b..8ff2a45c 100644 --- a/internal/core/identity/identity_test.go +++ b/internal/core/identity/identity_test.go @@ -121,6 +121,28 @@ func TestLoadPin_MissingFields(t *testing.T) { } } +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) { From 6e9966d14e5c69d5200520c2814d708ab40dd3ec Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:58:25 +0100 Subject: [PATCH 5/6] fix: close security-review findings on the identity gate (iss-62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F1 (blocker): the pre-commit hook failed OPEN when a present pin could not be parsed (non-canonical JSON keys, empty values, malformed) — silently allowing any identity. Now it fails CLOSED: a present-but-unreadable pin blocks the commit. Added a shell-level hook test (7 cases, incl. the three fail-closed regressions) — the coverage gap (F4) that hid this. - F2: `ahoy install --yes` no longer auto-pins the current git identity (a non-interactive run could pin a sandbox identity as canonical). Adoption now requires an interactive confirmation; the un-pinned gap otherwise remains. - F3: a present-but-unreadable pin is now a required doctor gap (adopted-but-broken gate), distinct from git being absent. make preflight green. Assisted-by: Claude:claude-opus-4-8 --- .githooks/pre-commit | 20 +++-- internal/core/ahoy/apply.go | 9 +- internal/core/ahoy/detect.go | 9 +- internal/core/ahoy/identity_gap_test.go | 14 ++++ internal/core/identity/hook_test.go | 106 ++++++++++++++++++++++++ 5 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 internal/core/identity/hook_test.go diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 81c61ddb..da4424dd 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -20,17 +20,27 @@ cd "$(git rev-parse --show-toplevel)" # 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; mirrors `abcd ahoy identity-check`. No-op when the repo has not -# adopted the gate (no pin). Runs FIRST — the name-guard below early-exits when -# no banlist is present, which must not skip this check. +# 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 [ -n "$want_name" ] && [ -n "$want_email" ] \ - && { [ "$have_name" != "$want_name" ] || [ "$have_email" != "$want_email" ]; }; then + 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 diff --git a/internal/core/ahoy/apply.go b/internal/core/ahoy/apply.go index d3ab4622..f8254167 100644 --- a/internal/core/ahoy/apply.go +++ b/internal/core/ahoy/apply.go @@ -64,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. @@ -107,6 +108,7 @@ 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) } @@ -116,8 +118,13 @@ func (a *applyCtx) note(path string) { a.writes = append(a.writes, path) } // 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.approved[ConfigChange] || !a.has("git_identity.unpinned") { + if a.autoYes || !a.approved[ConfigChange] || !a.has("git_identity.unpinned") { return } eff, err := identity.EffectiveIdentity(a.cwd) diff --git a/internal/core/ahoy/detect.go b/internal/core/ahoy/detect.go index 9dac4323..7afd92ca 100644 --- a/internal/core/ahoy/detect.go +++ b/internal/core/ahoy/detect.go @@ -1,6 +1,7 @@ package ahoy import ( + "os" "os/exec" "path/filepath" "sort" @@ -196,12 +197,16 @@ func detectIdentity(id RepoIdentity, idx *historyIndex) []Gap { 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: "ensure git is installed and this is a git repository", - Required: false, + FixHint: "fix the pin JSON in " + identity.PinRelPath + " (both name and email), or ensure git is available", + Required: pinPresent, Resolvable: false, }} } diff --git a/internal/core/ahoy/identity_gap_test.go b/internal/core/ahoy/identity_gap_test.go index ab510635..867a587d 100644 --- a/internal/core/ahoy/identity_gap_test.go +++ b/internal/core/ahoy/identity_gap_test.go @@ -86,6 +86,20 @@ func TestStepIdentityPin_WritesFromCurrentIdentity(t *testing.T) { } } +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{ 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) + } + }) + } +} From 3238cbe848623fce5775192479d752dcf98dd8e6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:00:25 +0100 Subject: [PATCH 6/6] =?UTF-8?q?chore:=20capture=20iss-63=20=E2=80=94=20ide?= =?UTF-8?q?ntity-gate=20security-review=20follow-ups=20(advisory)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Claude:claude-opus-4-8 --- .../issues/open/iss-63-identity-gate-followups.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .abcd/work/issues/open/iss-63-identity-gate-followups.md 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