Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .abcd/config/identity.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "REPPL",
"email": "77722411+REPPL@users.noreply.github.com"
}
11 changes: 11 additions & 0 deletions .abcd/work/issues/open/iss-63-identity-gate-followups.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions internal/core/ahoy/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions internal/core/ahoy/detect.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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)...)
Expand Down Expand Up @@ -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()
Expand Down
114 changes: 114 additions & 0 deletions internal/core/ahoy/identity_gap_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading