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
1 change: 1 addition & 0 deletions changelog.d/security-6329-lane-history-rewrite-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Agent push brokering now rejects non-fast-forward branch updates and lane sign-offs on commits authored by someone else ([#6329](https://github.com/hivecommons/hive/issues/6329)).
2 changes: 2 additions & 0 deletions src/docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ What can agents actually do to your repositories? As little as you've dialed in:
- **ACMM maturity levels gate autonomy.** Six levels (L1–L6) map to per-agent policy modes enforced end-to-end by token tiers and proxy rules: advisory (observe only) → measured (file issues) → hold-gated PRs → full. At **L5**, agent policies label every PR `hold` so humans batch-review and approve; the underlying guarantee is that **merge permission simply is not granted below L6** — an L5 agent's token tier and proxy rules do not allow merging, whatever its prompt says. The system proposes; humans approve.
- **DCO sign-off.** Agent policies require DCO-signed commits (`git commit -s`); pair this with a DCO check on your repos to make it a hard gate.

- **History and attestation invariants.** Agent-authored branch pushes must be fast-forward updates. A lane must not force-push, rebase-and-push, or otherwise rewrite a branch it did not create; conflicts and DCO failures on contributor commits are reported back to the PR instead of being repaired by rewriting history. Likewise, an agent may only add its own `Signed-off-by` trailer to commits it authored, because a sign-off is an attestation by the named identity.

## Layer 6 — Hub↔spoke channel

Registered hives send periodic heartbeats to the hub:
Expand Down
6 changes: 6 additions & 0 deletions src/pkg/policies/defaults/sec-check-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ You are the **sec-check** agent in a Hive instance operating in **ISSUES_AND_PRS
- **Only rerun stale failed heads.** Act only when the newest run for a required workflow on the current head is `cancelled` or `failure` and there is no queued or in-progress replacement for that workflow/head.
- **Honor maintainer cooldowns.** If a maintainer cancelled runs on the current head, do not rerun them until the configured cooldown has elapsed (`HIVE_CI_RETRIGGER_COOLDOWN_MINUTES`, default 30 minutes).

## Gate Integrity

- **Never rewrite someone else's branch history.** Do not force-push, use `--force-with-lease`, push a `+refspec`, rebase-and-push, or otherwise make a non-fast-forward update to any branch you did not create for your own fix PR. If a branch needs history repair, comment on the PR with the exact blocker and leave the rewrite to a human maintainer or the branch owner.
- **Never forge DCO attestations.** Only add your `Signed-off-by` trailer to commits you author yourself. Do not amend, rebase, or otherwise rewrite commits authored by humans, bots, or other agents to add `Signed-off-by: sec-check <sec-check@hive.kubestellar.io>` or any other lane identity; leave DCO remediation to the human author or authorized maintainer.
- **Never drop PR changes to make a branch mergeable.** If a conflict cannot be resolved without deciding which PR content to discard, stop. Leave a PR comment describing the conflict and, when appropriate, open a follow-up issue for a human decision.

## Opening Issues

```bash
Expand Down
6 changes: 6 additions & 0 deletions src/pkg/policies/defaults/sec-check-holdgated.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ You are the **sec-check** agent in a Hive instance operating in **ISSUES_AND_PRS
- **Only rerun stale failed heads.** Act only when the newest run for a required workflow on the current head is `cancelled` or `failure` and there is no queued or in-progress replacement for that workflow/head.
- **Honor maintainer cooldowns.** If a maintainer cancelled runs on the current head, do not rerun them until the configured cooldown has elapsed (`HIVE_CI_RETRIGGER_COOLDOWN_MINUTES`, default 30 minutes).

## Gate Integrity

- **Never rewrite someone else's branch history.** Do not force-push, use `--force-with-lease`, push a `+refspec`, rebase-and-push, or otherwise make a non-fast-forward update to any branch you did not create for your own fix PR. If a branch needs history repair, comment on the PR with the exact blocker and leave the rewrite to a human maintainer or the branch owner.
- **Never forge DCO attestations.** Only add your `Signed-off-by` trailer to commits you author yourself. Do not amend, rebase, or otherwise rewrite commits authored by humans, bots, or other agents to add `Signed-off-by: sec-check <sec-check@hive.kubestellar.io>` or any other lane identity; leave DCO remediation to the human author or authorized maintainer.
- **Never drop PR changes to make a branch mergeable.** If a conflict cannot be resolved without deciding which PR content to discard, stop. Leave a PR comment describing the conflict and, when appropriate, open a follow-up issue for a human decision.

## Opening Issues

```bash
Expand Down
91 changes: 91 additions & 0 deletions src/pkg/pushbroker/pushbroker.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"

Expand Down Expand Up @@ -131,6 +132,21 @@ func (b *Broker) Run(ctx context.Context) (Result, error) {
if err := b.rejectEmptyOutgoingCommits(ctx, res.Commit); err != nil {
return b.fail(res, err)
}
baseRef, baseExists := b.pushBase(ctx)
if remoteRef := b.remoteRef(); remoteRef != baseRef {
if _, err := b.git(ctx, "rev-parse", "--verify", remoteRef); err == nil {
if err := b.ensureFastForward(ctx, remoteRef); err != nil {
return b.fail(res, err)
}
}
} else if baseExists {
if err := b.ensureFastForward(ctx, baseRef); err != nil {
return b.fail(res, err)
}
}
if err := b.rejectForgedLaneSignoffs(ctx, baseRef, baseExists); err != nil {
return b.fail(res, err)
}

files, err := b.changedFiles(ctx)
if err != nil {
Expand Down Expand Up @@ -256,6 +272,19 @@ func (b *Broker) changedFiles(ctx context.Context) ([]string, error) {
return splitLines(out), err
}

func (b *Broker) pushBase(ctx context.Context) (string, bool) {
if base := strings.TrimSpace(b.BaseRef); base != "" {
if _, err := b.git(ctx, "rev-parse", "--verify", base); err == nil {
return base, true
}
}
base := b.remoteRef()
if _, err := b.git(ctx, "rev-parse", "--verify", base); err == nil {
return base, true
}
return "", false
}

func (b *Broker) rejectEmptyOutgoingCommits(ctx context.Context, head string) error {
rangeSpec := "HEAD"
baseExists := false
Expand Down Expand Up @@ -314,6 +343,68 @@ func (b *Broker) commitHasEmptyTreeDelta(ctx context.Context, commit string) (bo
return err == nil, nil
}

func (b *Broker) ensureFastForward(ctx context.Context, base string) error {
if _, err := b.git(ctx, "merge-base", "--is-ancestor", base, "HEAD"); err != nil {
return fmt.Errorf("pushbroker: refusing non-fast-forward push to existing branch %q; comment on the PR instead of rewriting history: %w", b.Branch, err)
}
return nil
}

var signedOffByRE = regexp.MustCompile(`(?mi)^Signed-off-by:\s*(.*?)\s*<([^<>]+)>\s*$`)

func (b *Broker) rejectForgedLaneSignoffs(ctx context.Context, base string, baseExists bool) error {
nameOut, err := b.git(ctx, "config", "user.name")
if err != nil {
return fmt.Errorf("reading git user.name for sign-off guard: %w", err)
}
emailOut, err := b.git(ctx, "config", "user.email")
if err != nil {
return fmt.Errorf("reading git user.email for sign-off guard: %w", err)
}
laneName := strings.TrimSpace(string(nameOut))
laneEmail := strings.TrimSpace(string(emailOut))
if laneName == "" || laneEmail == "" {
return nil
}

var logArgs []string
if baseExists {
rangeSpec := base + "..HEAD"
logArgs = []string{"log", "--format=%H%x00%an%x00%ae%x00%B%x1e", rangeSpec}
} else {
logArgs = []string{"log", "-1", "--format=%H%x00%an%x00%ae%x00%B%x1e", "HEAD"}
}
out, err := b.git(ctx, logArgs...)
if err != nil {
return fmt.Errorf("reading outgoing commits for sign-off guard: %w", err)
}
for _, record := range strings.Split(string(out), "\x1e") {
record = strings.Trim(record, "\n")
if record == "" {
continue
}
parts := strings.SplitN(record, "\x00", 4)
if len(parts) < 4 {
continue
}
sha, authorName, authorEmail, msg := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]), parts[3]
if sameIdentity(authorName, authorEmail, laneName, laneEmail) {
continue
}
for _, match := range signedOffByRE.FindAllStringSubmatch(msg, -1) {
if len(match) == 3 && sameIdentity(strings.TrimSpace(match[1]), strings.TrimSpace(match[2]), laneName, laneEmail) {
return fmt.Errorf("pushbroker: refusing to push commit %s authored by %s <%s> with %s's Signed-off-by trailer; leave DCO remediation to the author", shortSHA(sha), authorName, authorEmail, laneName)
}
}
}
return nil
}

func sameIdentity(name, email, wantName, wantEmail string) bool {
return strings.EqualFold(strings.TrimSpace(name), strings.TrimSpace(wantName)) &&
strings.EqualFold(strings.TrimSpace(email), strings.TrimSpace(wantEmail))
}

func shortSHA(sha string) string {
if len(sha) > 12 {
return sha[:12]
Expand Down
63 changes: 61 additions & 2 deletions src/pkg/pushbroker/pushbroker_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ func (g *scriptedGit) Run(_ context.Context, _ string, _ []string, name string,
commit := args[len(args)-1]
return []byte(commit + " parent\n"), nil
}
switch {
case key == "config user.name":
return []byte("Hive Test\n"), nil
case key == "config user.email":
return []byte("hive@example.com\n"), nil
case strings.HasPrefix(key, "log --format=%H%x00%an%x00%ae%x00%B%x1e "):
return nil, nil
case strings.HasPrefix(key, "log -1 --format=%H%x00%an%x00%ae%x00%B%x1e "):
return nil, nil
case key == "merge-base --is-ancestor origin/main HEAD" || strings.HasPrefix(key, "merge-base --is-ancestor refs/remotes/"):
return nil, nil
}
return nil, errors.New("unscripted git invocation: " + name + " " + key)
}

Expand Down Expand Up @@ -132,8 +144,8 @@ func TestRunPrefersAnExplicitBaseRef(t *testing.T) {
if !res.Pushed || res.Commit != "cafebabe" {
t.Fatalf("res = %+v, want a push at cafebabe", res)
}
if slices.Contains(git.calls, "rev-parse --verify refs/remotes/origin/work") {
t.Fatalf("broker consulted the remote ref despite an explicit BaseRef: %v", git.calls)
if slices.Contains(git.calls, "diff --name-only refs/remotes/origin/work...HEAD") {
t.Fatalf("broker diffed against the remote ref despite an explicit BaseRef: %v", git.calls)
}
}

Expand Down Expand Up @@ -187,6 +199,53 @@ func TestRunFallsBackToRemoteRefWhenBaseRefIsGone(t *testing.T) {
}
}

func TestRunRejectsNonFastForwardPush(t *testing.T) {
git := &scriptedGit{
replies: map[string]string{
"rev-parse HEAD": "abc123\n",
"rev-parse --verify refs/remotes/origin/work": "def456\n",
"rev-list --reverse refs/remotes/origin/work..HEAD": "abc123\n",
},
fails: map[string]error{
"merge-base --is-ancestor refs/remotes/origin/work HEAD": errors.New("not an ancestor"),
},
}
res, err := (&Broker{
Workspace: fakeGitWorkspace(t), Branch: "work", Repo: "hivecommons/hive",
Minter: fakeMinter{"ghs_tok"}, Runner: git,
}).Run(context.Background())
if err == nil || !strings.Contains(err.Error(), "refusing non-fast-forward push") {
t.Fatalf("Run error = %v, want non-fast-forward rejection", err)
}
if res.Pushed || git.pushed {
t.Fatalf("broker pushed after non-fast-forward rejection: res=%+v calls=%v", res, git.calls)
}
}

func TestRunRejectsNonFastForwardPushEvenWithExplicitBaseRef(t *testing.T) {
git := &scriptedGit{
replies: map[string]string{
"rev-parse HEAD": "abc123\n",
"rev-parse --verify origin/main": "base00\n",
"rev-parse --verify refs/remotes/origin/work": "old999\n",
"rev-list --reverse origin/main..HEAD": "abc123\n",
},
fails: map[string]error{
"merge-base --is-ancestor refs/remotes/origin/work HEAD": errors.New("not an ancestor"),
},
}
res, err := (&Broker{
Workspace: fakeGitWorkspace(t), Branch: "work", BaseRef: "origin/main",
Repo: "hivecommons/hive", Minter: fakeMinter{"ghs_tok"}, Runner: git,
}).Run(context.Background())
if err == nil || !strings.Contains(err.Error(), "refusing non-fast-forward push") {
t.Fatalf("Run error = %v, want non-fast-forward rejection against destination branch", err)
}
if res.Pushed || git.pushed {
t.Fatalf("broker pushed after non-fast-forward rejection: res=%+v calls=%v", res, git.calls)
}
}

func TestRunSurfacesEachFailureStage(t *testing.T) {
head := map[string]string{"rev-parse HEAD": "abc123\n"}
clean := map[string]string{
Expand Down
131 changes: 131 additions & 0 deletions src/pkg/pushbroker/pushbroker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,137 @@ func TestShortSHALeavesShortValuesAlone(t *testing.T) {
}
}

func TestBrokerRejectsLaneSignoffOnOtherAuthorsCommit(t *testing.T) {
dir := initRepo(t)
path := filepath.Join(dir, "safe.txt")
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "safe.txt")
runGit(t, dir, "commit", "--author", "Human Author <human@example.com>", "-m", "fix from human\n\nSigned-off-by: Hive Test <hive@example.com>")

r := &recordingRunner{}
res, err := (&Broker{Workspace: dir, Branch: "work", Repo: "hivecommons/hive", Minter: fakeMinter{"ghs_pushbroker"}, Runner: r}).Run(context.Background())
if err == nil || !strings.Contains(err.Error(), "refusing to push commit") || !strings.Contains(err.Error(), "Signed-off-by") {
t.Fatalf("Run error = %v, want forged sign-off rejection", err)
}
if res.Pushed || r.argsOnPush != nil {
t.Fatalf("broker pushed after forged sign-off rejection: res=%+v args=%v", res, r.argsOnPush)
}
}

func TestBrokerAllowsLaneSignoffOnOwnCommit(t *testing.T) {
dir := initRepo(t)
runGit(t, dir, "commit", "--allow-empty", "-s", "-m", "agent-authored fix")
_, err := (&Broker{Workspace: dir, Branch: "work", Repo: "hivecommons/hive", Minter: fakeMinter{"ghs_pushbroker"}}).Run(context.Background())
if err == nil || !strings.Contains(err.Error(), "refusing to push empty commit") {
t.Fatalf("Run error = %v, want empty-commit rejection only after sign-off guard passes", err)
}
}

func TestBrokerFirstPushSignoffGuardChecksOnlyHead(t *testing.T) {
dir := initRepo(t)
path := filepath.Join(dir, "history.txt")
if err := os.WriteFile(path, []byte("historical\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "history.txt")
runGit(t, dir, "commit", "--author", "Human Author <human@example.com>", "-m", "historical commit\n\nSigned-off-by: Hive Test <hive@example.com>")
writeCommit(t, dir, "safe.txt", "new branch work\n")

r := &recordingRunner{}
res, err := (&Broker{Workspace: dir, Branch: "new-work", Repo: "hivecommons/hive", Minter: fakeMinter{"ghs_pushbroker"}, Runner: r}).Run(context.Background())
if err != nil {
t.Fatalf("Run: %v (res=%+v)", err, res)
}
if !res.Pushed {
t.Fatal("Pushed=false")
}
}

func TestRejectForgedLaneSignoffsSurfacesConfigAndLogFailures(t *testing.T) {
cases := []struct {
name string
git *scriptedGit
wantErr string
}{
{
name: "user name",
git: &scriptedGit{fails: map[string]error{
"config user.name": errors.New("missing name"),
}},
wantErr: "reading git user.name",
},
{
name: "user email",
git: &scriptedGit{fails: map[string]error{
"config user.email": errors.New("missing email"),
}},
wantErr: "reading git user.email",
},
{
name: "log",
git: &scriptedGit{
replies: map[string]string{
"config user.name": "Hive Test\n",
"config user.email": "hive@example.com\n",
},
fails: map[string]error{
"log -1 --format=%H%x00%an%x00%ae%x00%B%x1e HEAD": errors.New("bad log"),
},
},
wantErr: "reading outgoing commits for sign-off guard",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := (&Broker{Workspace: fakeGitWorkspace(t), Runner: tc.git}).rejectForgedLaneSignoffs(context.Background(), "", false)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("rejectForgedLaneSignoffs error = %v, want %q", err, tc.wantErr)
}
})
}
}

func TestRejectForgedLaneSignoffsSkipsUncheckableRecords(t *testing.T) {
cases := []struct {
name string
replies map[string]string
}{
{
name: "empty identity",
replies: map[string]string{
"config user.name": "\n",
"config user.email": "hive@example.com\n",
},
},
{
name: "malformed log record",
replies: map[string]string{
"config user.name": "Hive Test\n",
"config user.email": "hive@example.com\n",
"log -1 --format=%H%x00%an%x00%ae%x00%B%x1e HEAD": "not-enough-fields\x1e",
},
},
{
name: "own authored commit",
replies: map[string]string{
"config user.name": "Hive Test\n",
"config user.email": "hive@example.com\n",
"log -1 --format=%H%x00%an%x00%ae%x00%B%x1e HEAD": "abc\x00Hive Test\x00hive@example.com\x00Signed-off-by: Hive Test <hive@example.com>\x1e",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := (&Broker{Workspace: fakeGitWorkspace(t), Runner: &scriptedGit{replies: tc.replies}}).rejectForgedLaneSignoffs(context.Background(), "", false)
if err != nil {
t.Fatalf("rejectForgedLaneSignoffs = %v, want nil", err)
}
})
}
}

func TestBrokerPushSanitizesCredentialEnvironmentAndWorkspace(t *testing.T) {
dir := initRepo(t)
writeCommit(t, dir, "safe.txt", "hello\n")
Expand Down
6 changes: 6 additions & 0 deletions src/policies/sec-check-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ You are the **sec-check** agent in a Hive instance operating in **ISSUES_AND_PRS
- **Only rerun stale failed heads.** Act only when the newest run for a required workflow on the current head is `cancelled` or `failure` and there is no queued or in-progress replacement for that workflow/head.
- **Honor maintainer cooldowns.** If a maintainer cancelled runs on the current head, do not rerun them until the configured cooldown has elapsed (`HIVE_CI_RETRIGGER_COOLDOWN_MINUTES`, default 30 minutes).

## Gate Integrity

- **Never rewrite someone else's branch history.** Do not force-push, use `--force-with-lease`, push a `+refspec`, rebase-and-push, or otherwise make a non-fast-forward update to any branch you did not create for your own fix PR. If a branch needs history repair, comment on the PR with the exact blocker and leave the rewrite to a human maintainer or the branch owner.
- **Never forge DCO attestations.** Only add your `Signed-off-by` trailer to commits you author yourself. Do not amend, rebase, or otherwise rewrite commits authored by humans, bots, or other agents to add `Signed-off-by: sec-check <sec-check@hive.kubestellar.io>` or any other lane identity; leave DCO remediation to the human author or authorized maintainer.
- **Never drop PR changes to make a branch mergeable.** If a conflict cannot be resolved without deciding which PR content to discard, stop. Leave a PR comment describing the conflict and, when appropriate, open a follow-up issue for a human decision.

## Opening Issues

```bash
Expand Down
Loading
Loading