fix(agentctl): allow git pull --rebase past the flag allowlist (#2501)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-10 21:03:33 +02:00
committed by GitHub
parent e9d3cb02cb
commit f25a2bee62
3 changed files with 319 additions and 2 deletions
@@ -0,0 +1,272 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/common/logger"
)
// postGitJSON drives a git endpoint through the real router and decodes the
// handler's result. Anything other than 200 is fatal: these handlers answer
// operational failures with 200 + Success:false, so a non-200 means the request
// never reached the GitOperator at all.
func postGitJSON(t *testing.T, srv *Server, path, body string) process.GitOperationResult {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
srv.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("POST %s status = %d, want 200: %s", path, rec.Code, rec.Body.String())
}
var result process.GitOperationResult
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatalf("decode %s response: %v (body %s)", path, err, rec.Body.String())
}
return result
}
func postGitPull(t *testing.T, srv *Server, body string) process.GitOperationResult {
t.Helper()
return postGitJSON(t, srv, "/api/v1/git/pull", body)
}
// newPullAPIServer wires the api Server over an existing working copy.
func newPullAPIServer(t *testing.T, repoDir string) *Server {
t.Helper()
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error"})
cfg := &config.InstanceConfig{WorkDir: repoDir}
mgr := process.NewManager(cfg, log)
t.Cleanup(func() { _ = mgr.StopForTeardown(context.Background()) })
return NewServer(cfg, mgr, nil, nil, log)
}
// advanceOriginMain pushes one commit to the bare origin behind repoDir without
// touching repoDir's working copy, so the next pull has something to integrate.
// Returns the SHA of the new origin/main tip.
func advanceOriginMain(t *testing.T, repoDir, file, content, message string) string {
t.Helper()
remoteURL := strings.TrimSpace(runGitAPI(t, repoDir, "remote", "get-url", "origin"))
otherDir := t.TempDir()
runGitAPI(t, otherDir, "clone", remoteURL, ".")
runGitAPI(t, otherDir, "config", "user.email", "other@test.com")
runGitAPI(t, otherDir, "config", "user.name", "Other User")
runGitAPI(t, otherDir, "config", "core.hooksPath", "/dev/null")
writeFileAPI(t, otherDir, file, content)
runGitAPI(t, otherDir, "add", ".")
runGitAPI(t, otherDir, "commit", "-m", message)
runGitAPI(t, otherDir, "push", "origin", "main")
return strings.TrimSpace(runGitAPI(t, otherDir, "rev-parse", "HEAD"))
}
// commitLocally adds one commit on the current branch of repoDir.
func commitLocally(t *testing.T, repoDir, file, content, message string) {
t.Helper()
writeFileAPI(t, repoDir, file, content)
runGitAPI(t, repoDir, "add", ".")
runGitAPI(t, repoDir, "commit", "-m", message)
}
// TestHandleGitPull_RebaseReplaysLocalCommitOntoOrigin drives the documented
// {"rebase": true} option end to end against a real bare origin. Local main and
// origin/main have each moved on by one commit, so a successful pull must
// produce a linear history with the local commit replayed on top of the remote
// tip — a merge commit would mean the rebase flag was ignored.
//
// The flag allowlist in securityutil.IsKnownSafeGitFlag used to omit --rebase,
// so this path returned 200 with {"success":false,"error":"potentially unsafe
// flag: --rebase"} for every caller.
func TestHandleGitPull_RebaseReplaysLocalCommitOntoOrigin(t *testing.T) {
repoDir, cleanup := setupAPITestRepo(t)
t.Cleanup(cleanup)
remoteTip := advanceOriginMain(t, repoDir, "remote.txt", "remote work\n", "feat: remote work")
commitLocally(t, repoDir, "local.txt", "local work\n", "feat: local work")
localMessage := strings.TrimSpace(runGitAPI(t, repoDir, "log", "-1", "--format=%s"))
srv := newPullAPIServer(t, repoDir)
result := postGitPull(t, srv, `{"rebase": true}`)
if !result.Success {
t.Fatalf("pull --rebase failed: error=%q output=%q conflicts=%v",
result.Error, result.Output, result.ConflictFiles)
}
if result.Operation != "pull" {
t.Errorf("result.Operation = %q, want %q", result.Operation, "pull")
}
// Rebase, not merge: the local commit is still the tip, its parent is the
// remote tip, and the branch carries no merge commit.
if got := strings.TrimSpace(runGitAPI(t, repoDir, "log", "-1", "--format=%s")); got != localMessage {
t.Errorf("HEAD subject = %q, want the replayed local commit %q", got, localMessage)
}
if got := strings.TrimSpace(runGitAPI(t, repoDir, "rev-parse", "HEAD^")); got != remoteTip {
t.Errorf("HEAD^ = %q, want the origin/main tip %q — the local commit was not replayed on top", got, remoteTip)
}
if merges := strings.TrimSpace(runGitAPI(t, repoDir, "rev-list", "--merges", "HEAD")); merges != "" {
t.Errorf("branch contains merge commits %q — the pull merged instead of rebasing", merges)
}
// Both files present means neither side's work was dropped.
for _, name := range []string{"remote.txt", "local.txt"} {
if _, err := os.Stat(filepath.Join(repoDir, name)); err != nil {
t.Errorf("expected %s in the working tree after the rebase: %v", name, err)
}
}
}
// TestHandleGitPull_RebaseConflictAutoAborts covers Pull's conflict-recovery
// path, which runs "rebase --abort" so the caller is not left in a detached
// mid-rebase state. --abort was missing from the same allowlist, so the abort
// itself failed and the repository stayed stuck.
func TestHandleGitPull_RebaseConflictAutoAborts(t *testing.T) {
repoDir, cleanup := setupAPITestRepo(t)
t.Cleanup(cleanup)
// Both sides edit the same file so the replay conflicts.
advanceOriginMain(t, repoDir, "shared.txt", "remote version\n", "feat: remote edit")
commitLocally(t, repoDir, "shared.txt", "local version\n", "feat: local edit")
localTip := strings.TrimSpace(runGitAPI(t, repoDir, "rev-parse", "HEAD"))
srv := newPullAPIServer(t, repoDir)
result := postGitPull(t, srv, `{"rebase": true}`)
if result.Success {
t.Fatalf("pull --rebase reported success despite a conflicting change: %+v", result)
}
if len(result.ConflictFiles) == 0 {
t.Fatalf("expected the conflicting file to be reported, got none (error=%q output=%q)",
result.Error, result.Output)
}
if result.ConflictFiles[0] != "shared.txt" {
t.Errorf("ConflictFiles = %v, want [shared.txt]", result.ConflictFiles)
}
// The abort must have run: no in-progress rebase state, and HEAD is back
// on the local commit with a clean tree.
for _, dir := range []string{"rebase-merge", "rebase-apply"} {
if _, err := os.Stat(filepath.Join(repoDir, ".git", dir)); err == nil {
t.Errorf(".git/%s still exists — the conflicting rebase was not aborted", dir)
}
}
if got := strings.TrimSpace(runGitAPI(t, repoDir, "rev-parse", "HEAD")); got != localTip {
t.Errorf("HEAD = %q after the aborted rebase, want the pre-pull local tip %q", got, localTip)
}
if status := strings.TrimSpace(runGitAPI(t, repoDir, "status", "--porcelain")); status != "" {
t.Errorf("working tree is dirty after the abort:\n%s", status)
}
}
// runGitAPIExpectFailure runs git and requires a non-zero exit. Leaving a
// repository mid-rebase or mid-merge means running a command that conflicts,
// so the failure is the point — runGitAPI would call t.Fatalf on it.
func runGitAPIExpectFailure(t *testing.T, dir string, args ...string) {
t.Helper()
full := append([]string{
"-C", dir,
"-c", "commit.gpgsign=false",
"-c", "tag.gpgsign=false",
}, args...)
cmd := exec.Command("git", full...)
env := make([]string, 0, len(os.Environ()))
for _, e := range os.Environ() {
if strings.HasPrefix(e, "GIT_") {
continue
}
env = append(env, e)
}
cmd.Env = env
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("git %v was expected to conflict but succeeded:\n%s", args, out)
}
}
// setupConflictingBranches leaves repoDir checked out on feature/x, whose only
// commit adds shared.txt with content that conflicts with main's. Returns the
// feature tip, which an abort must restore.
func setupConflictingBranches(t *testing.T, repoDir string) string {
t.Helper()
runGitAPI(t, repoDir, "checkout", "-b", "feature/x")
commitLocally(t, repoDir, "shared.txt", "feature version\n", "feat: feature edit")
featureTip := strings.TrimSpace(runGitAPI(t, repoDir, "rev-parse", "HEAD"))
runGitAPI(t, repoDir, "checkout", "main")
commitLocally(t, repoDir, "shared.txt", "main version\n", "feat: main edit")
runGitAPI(t, repoDir, "checkout", "feature/x")
return featureTip
}
// assertAbortRestoredRepo pins the post-abort state: no in-progress operation
// left behind, HEAD back where it started, and a clean working tree.
func assertAbortRestoredRepo(t *testing.T, repoDir, wantHead string) {
t.Helper()
for _, name := range []string{"rebase-merge", "rebase-apply", "MERGE_HEAD"} {
if _, err := os.Stat(filepath.Join(repoDir, ".git", name)); err == nil {
t.Errorf(".git/%s still exists — the operation was not aborted", name)
}
}
if got := strings.TrimSpace(runGitAPI(t, repoDir, "rev-parse", "HEAD")); got != wantHead {
t.Errorf("HEAD = %q after the abort, want %q", got, wantHead)
}
if status := strings.TrimSpace(runGitAPI(t, repoDir, "status", "--porcelain")); status != "" {
t.Errorf("working tree is dirty after the abort:\n%s", status)
}
}
// TestHandleGitAbort_ClearsInProgressRebase drives POST /api/v1/git/abort with
// {"operation": "rebase"} against a repository genuinely stuck mid-rebase.
// GitOperator.Abort issues "rebase --abort", which the flag allowlist rejected,
// so this endpoint failed for every caller regardless of repository state.
func TestHandleGitAbort_ClearsInProgressRebase(t *testing.T) {
repoDir, cleanup := setupAPITestRepo(t)
t.Cleanup(cleanup)
featureTip := setupConflictingBranches(t, repoDir)
runGitAPIExpectFailure(t, repoDir, "rebase", "main")
if _, err := os.Stat(filepath.Join(repoDir, ".git", "rebase-merge")); err != nil {
t.Fatalf("fixture did not leave a rebase in progress: %v", err)
}
srv := newPullAPIServer(t, repoDir)
result := postGitJSON(t, srv, "/api/v1/git/abort", `{"operation": "rebase"}`)
if !result.Success {
t.Fatalf("abort rebase failed: error=%q output=%q", result.Error, result.Output)
}
if result.Operation != "abort" {
t.Errorf("result.Operation = %q, want %q", result.Operation, "abort")
}
assertAbortRestoredRepo(t, repoDir, featureTip)
}
// TestHandleGitAbort_ClearsInProgressMerge is the "merge" half of the same
// endpoint; it issues "merge --abort" through the same allowlist.
func TestHandleGitAbort_ClearsInProgressMerge(t *testing.T) {
repoDir, cleanup := setupAPITestRepo(t)
t.Cleanup(cleanup)
featureTip := setupConflictingBranches(t, repoDir)
runGitAPIExpectFailure(t, repoDir, "merge", "main")
if _, err := os.Stat(filepath.Join(repoDir, ".git", "MERGE_HEAD")); err != nil {
t.Fatalf("fixture did not leave a merge in progress: %v", err)
}
srv := newPullAPIServer(t, repoDir)
result := postGitJSON(t, srv, "/api/v1/git/abort", `{"operation": "merge"}`)
if !result.Success {
t.Fatalf("abort merge failed: error=%q output=%q", result.Error, result.Output)
}
assertAbortRestoredRepo(t, repoDir, featureTip)
}
@@ -55,6 +55,20 @@ func IsValidBaseBranchRef(ref string) bool {
// This prevents argument injection where user input could introduce malicious flags.
// Only flags actually used by the Kandev codebase are whitelisted.
func IsKnownSafeGitFlag(arg string) bool {
// Flags allowed only as an exact argument, because the prefix match below
// would admit variants this codebase never issues: "--rebase" would let
// through "--rebase-merges" and "--rebase=interactive" (which spawns an
// editor), and "--abort" would let through "--abort-*".
exactFlags := []string{
"--", // Path separator - everything after this is treated as paths, not flags
"--rebase",
"--abort",
}
for _, safe := range exactFlags {
if arg == safe {
return true
}
}
// Whitelist of git flags actually used by our codebase
safeFlags := []string{
"-m", "-M", "-n", "--set-upstream", "--all", "--porcelain", "--short",
@@ -63,10 +77,9 @@ func IsKnownSafeGitFlag(arg string) bool {
"--amend", "--allow-empty", "--soft", "--mixed", "--hard",
"--cached", "--force", "--source=HEAD", "--staged", "--worktree",
"--dry-run", "--get-all", "--first-parent", "--is-ancestor",
"--", // Path separator - everything after this is treated as paths, not flags
}
for _, safe := range safeFlags {
if arg == safe || (safe != "--" && strings.HasPrefix(arg, safe)) {
if strings.HasPrefix(arg, safe) {
return true
}
}
@@ -42,3 +42,35 @@ func TestIsKnownSafeGitFlagRejectsPushOptionPrefixes(t *testing.T) {
}
}
}
// TestIsKnownSafeGitFlagAllowsRebaseAndAbort pins the two flags the git-pull
// rebase path needs. GitOperator.Pull builds "pull --rebase <remote> <branch>"
// and, when the rebase conflicts, recovers with "rebase --abort"; both were
// rejected here, so POST /api/v1/git/pull {"rebase":true} could never succeed
// and POST /api/v1/git/abort never worked at all.
func TestIsKnownSafeGitFlagAllowsRebaseAndAbort(t *testing.T) {
for _, flag := range []string{"--rebase", "--abort"} {
if !IsKnownSafeGitFlag(flag) {
t.Fatalf("IsKnownSafeGitFlag(%q) = false, want true — the git pull --rebase path needs it", flag)
}
}
}
// TestIsKnownSafeGitFlagRejectsRebaseAndAbortVariants pins that --rebase and
// --abort are allowed as exact arguments only. The bulk of the allowlist
// matches by prefix, which for these two would also admit "--rebase-merges",
// "--rebase=interactive" (spawns an editor), and "--abort-on-*", none of which
// this codebase issues.
func TestIsKnownSafeGitFlagRejectsRebaseAndAbortVariants(t *testing.T) {
for _, flag := range []string{
"--rebase-merges",
"--rebase=interactive",
"--rebase=i",
"--abort-on-error",
"--aborted",
} {
if IsKnownSafeGitFlag(flag) {
t.Fatalf("IsKnownSafeGitFlag(%q) = true, want false — only the exact flags are allowed", flag)
}
}
}