Skip to content

Commit 3297f04

Browse files
sallyomclaude
andcommitted
feat: Improve git repository context UX (Issue #376)
Enhances the user experience for adding git repositories as context. Frontend UX Improvements: 1. Enhanced Add Context Modal - Repository options with base/feature branch configuration, protected branch detection, sync configuration 2. Improved Repository Dialog - Branch configuration with branch fetching, protected branch warnings, sync support 3. Enhanced Repository Display - Visual badges, color-coded branch pills, improved layout Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: sallyom <somalley@redhat.com>
1 parent f5fac44 commit 3297f04

File tree

14 files changed

+1264
-131
lines changed

14 files changed

+1264
-131
lines changed

components/backend/handlers/helpers.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"log"
77
"math"
8+
"strings"
89
"time"
910

1011
authv1 "k8s.io/api/authorization/v1"
@@ -74,3 +75,127 @@ func ValidateSecretAccess(ctx context.Context, k8sClient kubernetes.Interface, n
7475

7576
return nil
7677
}
78+
79+
// isProtectedBranch checks if a branch name is commonly protected
80+
func isProtectedBranch(branch string) bool {
81+
if branch == "" {
82+
return false
83+
}
84+
protectedNames := []string{
85+
"main", "master", "develop", "dev", "development",
86+
"production", "prod", "staging", "stage", "qa", "test", "stable",
87+
}
88+
branchLower := strings.ToLower(strings.TrimSpace(branch))
89+
for _, protected := range protectedNames {
90+
if branchLower == protected {
91+
return true
92+
}
93+
}
94+
return false
95+
}
96+
97+
// isValidGitBranchName validates a user-supplied branch name against git branch naming rules
98+
// and shell injection risks. Returns true if the branch name is safe to use.
99+
// Security: This prevents command injection by rejecting shell metacharacters.
100+
func isValidGitBranchName(branch string) bool {
101+
if branch == "" {
102+
return false
103+
}
104+
105+
// Reject if longer than 255 characters (git limit)
106+
if len(branch) > 255 {
107+
return false
108+
}
109+
110+
// Reject shell metacharacters that could lead to command injection
111+
// CRITICAL: These characters could break out of git commands in wrapper.py
112+
shellMetachars := []rune{';', '&', '|', '$', '`', '\\', '\n', '\r', '\t', '<', '>', '(', ')', '{', '}', '\'', '"', ' '}
113+
for _, char := range shellMetachars {
114+
if strings.ContainsRune(branch, char) {
115+
return false
116+
}
117+
}
118+
119+
// Reject git control characters and patterns
120+
gitControlChars := []string{"..", "~", "^", ":", "?", "*", "[", "@{"}
121+
for _, pattern := range gitControlChars {
122+
if strings.Contains(branch, pattern) {
123+
return false
124+
}
125+
}
126+
127+
// Cannot start or end with dot or slash
128+
if strings.HasPrefix(branch, ".") || strings.HasSuffix(branch, ".") ||
129+
strings.HasPrefix(branch, "/") || strings.HasSuffix(branch, "/") {
130+
return false
131+
}
132+
133+
// Cannot contain consecutive slashes
134+
if strings.Contains(branch, "//") {
135+
return false
136+
}
137+
138+
// Must contain only valid characters: alphanumeric, dash, underscore, slash, dot
139+
for _, r := range branch {
140+
valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
141+
(r >= '0' && r <= '9') || r == '-' || r == '_' || r == '/' || r == '.'
142+
if !valid {
143+
return false
144+
}
145+
}
146+
147+
return true
148+
}
149+
150+
// sanitizeBranchName converts a display name to a valid git branch name
151+
func sanitizeBranchName(name string) string {
152+
// Replace spaces with hyphens
153+
name = strings.ReplaceAll(name, " ", "-")
154+
// Remove or replace invalid characters for git branch names
155+
// Valid: alphanumeric, dash, underscore, slash, dot (but not at start/end)
156+
var result strings.Builder
157+
for _, r := range name {
158+
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
159+
(r >= '0' && r <= '9') || r == '-' || r == '_' || r == '/' {
160+
result.WriteRune(r)
161+
}
162+
}
163+
sanitized := result.String()
164+
// Trim leading/trailing dashes or slashes
165+
sanitized = strings.Trim(sanitized, "-/")
166+
return sanitized
167+
}
168+
169+
// generateWorkingBranch generates a working branch name based on the session and repo context
170+
// Returns the branch name to use for the session
171+
func generateWorkingBranch(sessionDisplayName, sessionID, requestedBranch string, allowProtectedWork bool) string {
172+
// If user explicitly requested a branch
173+
if requestedBranch != "" {
174+
// Check if it's protected and user hasn't allowed working on it
175+
if isProtectedBranch(requestedBranch) && !allowProtectedWork {
176+
// Create a temporary working branch to protect the base branch
177+
sessionIDShort := sessionID
178+
if len(sessionID) > 8 {
179+
sessionIDShort = sessionID[:8]
180+
}
181+
return fmt.Sprintf("work/%s/%s", requestedBranch, sessionIDShort)
182+
}
183+
// User requested non-protected branch or explicitly allowed protected work
184+
return requestedBranch
185+
}
186+
187+
// No branch requested - generate from session name
188+
if sessionDisplayName != "" {
189+
sanitized := sanitizeBranchName(sessionDisplayName)
190+
if sanitized != "" {
191+
return sanitized
192+
}
193+
}
194+
195+
// Fallback: use session ID
196+
sessionIDShort := sessionID
197+
if len(sessionID) > 8 {
198+
sessionIDShort = sessionID[:8]
199+
}
200+
return fmt.Sprintf("session-%s", sessionIDShort)
201+
}

components/backend/handlers/sessions.go

Lines changed: 167 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -599,41 +599,135 @@ func CreateSession(c *gin.Context) {
599599
if metadata["annotations"] == nil {
600600
metadata["annotations"] = make(map[string]interface{})
601601
}
602-
annotations := metadata["annotations"].(map[string]interface{})
602+
annotations, found, err := unstructured.NestedMap(metadata, "annotations")
603+
if err != nil || !found {
604+
log.Printf("Failed to get annotations from metadata for parent session %s: %v", req.ParentSessionID, err)
605+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session metadata"})
606+
return
607+
}
603608
annotations["vteam.ambient-code/parent-session-id"] = req.ParentSessionID
609+
// Persist annotations back to metadata
610+
if err := unstructured.SetNestedMap(metadata, annotations, "annotations"); err != nil {
611+
log.Printf("Failed to set annotations for parent session %s: %v", req.ParentSessionID, err)
612+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session metadata"})
613+
return
614+
}
615+
// Persist metadata back to session
616+
if err := unstructured.SetNestedMap(session, metadata, "metadata"); err != nil {
617+
log.Printf("Failed to set metadata for session %s: %v", name, err)
618+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session metadata"})
619+
return
620+
}
604621
log.Printf("Creating continuation session from parent %s (operator will handle temp pod cleanup)", req.ParentSessionID)
605622
// Note: Operator will delete temp pod when session starts (desired-phase=Running)
606623
}
607624

608625
if len(envVars) > 0 {
609-
spec := session["spec"].(map[string]interface{})
626+
spec, found, err := unstructured.NestedMap(session, "spec")
627+
if err != nil || !found {
628+
log.Printf("Failed to get spec from session %s: %v", name, err)
629+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session spec"})
630+
return
631+
}
610632
spec["environmentVariables"] = envVars
633+
// Persist spec back to session
634+
if err := unstructured.SetNestedMap(session, spec, "spec"); err != nil {
635+
log.Printf("Failed to set spec for session %s: %v", name, err)
636+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session spec"})
637+
return
638+
}
611639
}
612640

613641
// Interactive flag
614642
if req.Interactive != nil {
615-
session["spec"].(map[string]interface{})["interactive"] = *req.Interactive
643+
spec, found, err := unstructured.NestedMap(session, "spec")
644+
if err != nil || !found {
645+
log.Printf("Failed to get spec from session %s: %v", name, err)
646+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session spec"})
647+
return
648+
}
649+
spec["interactive"] = *req.Interactive
650+
// Persist spec back to session
651+
if err := unstructured.SetNestedMap(session, spec, "spec"); err != nil {
652+
log.Printf("Failed to set spec for session %s: %v", name, err)
653+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session spec"})
654+
return
655+
}
616656
}
617657

618658
// AutoPushOnComplete flag
619659
if req.AutoPushOnComplete != nil {
620-
session["spec"].(map[string]interface{})["autoPushOnComplete"] = *req.AutoPushOnComplete
660+
spec, found, err := unstructured.NestedMap(session, "spec")
661+
if err != nil || !found {
662+
log.Printf("Failed to get spec from session %s: %v", name, err)
663+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session spec"})
664+
return
665+
}
666+
spec["autoPushOnComplete"] = *req.AutoPushOnComplete
667+
// Persist spec back to session
668+
if err := unstructured.SetNestedMap(session, spec, "spec"); err != nil {
669+
log.Printf("Failed to set spec for session %s: %v", name, err)
670+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session spec"})
671+
return
672+
}
621673
}
622674

623675
// Set multi-repo configuration on spec (simplified format)
676+
// Generate working branch names upfront based on session context
624677
{
625-
spec := session["spec"].(map[string]interface{})
678+
spec, found, err := unstructured.NestedMap(session, "spec")
679+
if err != nil || !found {
680+
log.Printf("Failed to get spec from session %s: %v", name, err)
681+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session spec"})
682+
return
683+
}
626684
if len(req.Repos) > 0 {
627685
arr := make([]map[string]interface{}, 0, len(req.Repos))
628686
for _, r := range req.Repos {
629-
m := map[string]interface{}{"url": r.URL}
630-
if r.Branch != nil {
631-
m["branch"] = *r.Branch
687+
// Determine the working branch to use
688+
var requestedBranch string
689+
if r.WorkingBranch != nil {
690+
requestedBranch = strings.TrimSpace(*r.WorkingBranch)
691+
} else if r.Branch != nil {
692+
requestedBranch = strings.TrimSpace(*r.Branch)
693+
}
694+
695+
// Validate user-supplied branch names to prevent command injection
696+
if requestedBranch != "" && !isValidGitBranchName(requestedBranch) {
697+
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid branch name format: %s", requestedBranch)})
698+
return
699+
}
700+
701+
allowProtected := false
702+
if r.AllowProtectedWork != nil {
703+
allowProtected = *r.AllowProtectedWork
704+
}
705+
706+
// Generate the actual branch name that will be used
707+
workingBranch := generateWorkingBranch(
708+
req.DisplayName,
709+
name, // session name (unique ID)
710+
requestedBranch,
711+
allowProtected,
712+
)
713+
714+
// Wrap in 'input' object to match runner expectations
715+
m := map[string]interface{}{
716+
"input": map[string]interface{}{
717+
"url": r.URL,
718+
"branch": workingBranch,
719+
},
632720
}
633721
arr = append(arr, m)
634722
}
635723
spec["repos"] = arr
636724
}
725+
// Persist spec back to session
726+
if err := unstructured.SetNestedMap(session, spec, "spec"); err != nil {
727+
log.Printf("Failed to set spec for session %s: %v", name, err)
728+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session spec"})
729+
return
730+
}
637731
}
638732

639733
// Add userContext derived from authenticated caller; ignore client-supplied userId
@@ -661,11 +755,23 @@ func CreateSession(c *gin.Context) {
661755
if len(groups) == 0 && req.UserContext != nil {
662756
groups = req.UserContext.Groups
663757
}
664-
session["spec"].(map[string]interface{})["userContext"] = map[string]interface{}{
758+
spec, found, err := unstructured.NestedMap(session, "spec")
759+
if err != nil || !found {
760+
log.Printf("Failed to get spec from session %s: %v", name, err)
761+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session spec"})
762+
return
763+
}
764+
spec["userContext"] = map[string]interface{}{
665765
"userId": uid,
666766
"displayName": displayName,
667767
"groups": groups,
668768
}
769+
// Persist spec back to session
770+
if err := unstructured.SetNestedMap(session, spec, "spec"); err != nil {
771+
log.Printf("Failed to set spec for session %s: %v", name, err)
772+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session spec"})
773+
return
774+
}
669775
}
670776
}
671777

@@ -1406,19 +1512,21 @@ func AddRepo(c *gin.Context) {
14061512
}
14071513

14081514
var req struct {
1409-
URL string `json:"url" binding:"required"`
1410-
Branch string `json:"branch"`
1515+
URL string `json:"url" binding:"required"`
1516+
Branch string `json:"branch"`
1517+
WorkingBranch string `json:"workingBranch"`
1518+
AllowProtectedWork bool `json:"allowProtectedWork"`
1519+
Sync *struct {
1520+
URL string `json:"url"`
1521+
Branch string `json:"branch"`
1522+
} `json:"sync"`
14111523
}
14121524

14131525
if err := c.ShouldBindJSON(&req); err != nil {
14141526
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
14151527
return
14161528
}
14171529

1418-
if req.Branch == "" {
1419-
req.Branch = "main"
1420-
}
1421-
14221530
gvr := GetAgenticSessionV1Alpha1Resource()
14231531
item, err := k8sDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{})
14241532
if err != nil {
@@ -1447,10 +1555,52 @@ func AddRepo(c *gin.Context) {
14471555
repos = []interface{}{}
14481556
}
14491557

1558+
// Determine the requested branch
1559+
requestedBranch := req.WorkingBranch
1560+
if requestedBranch == "" {
1561+
requestedBranch = req.Branch
1562+
}
1563+
1564+
// Validate user-supplied branch names to prevent command injection
1565+
if requestedBranch != "" && !isValidGitBranchName(requestedBranch) {
1566+
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid branch name format: %s", requestedBranch)})
1567+
return
1568+
}
1569+
1570+
// Get session display name for branch generation
1571+
displayName := ""
1572+
if dn, ok := spec["displayName"].(string); ok {
1573+
displayName = dn
1574+
}
1575+
1576+
// Generate the actual working branch name
1577+
workingBranch := generateWorkingBranch(
1578+
displayName,
1579+
sessionName,
1580+
requestedBranch,
1581+
req.AllowProtectedWork,
1582+
)
1583+
1584+
// Wrap in 'input' object to match runner expectations
14501585
newRepo := map[string]interface{}{
1451-
"url": req.URL,
1452-
"branch": req.Branch,
1586+
"input": map[string]interface{}{
1587+
"url": req.URL,
1588+
"branch": workingBranch,
1589+
},
1590+
}
1591+
1592+
// Add sync configuration if provided
1593+
if req.Sync != nil && strings.TrimSpace(req.Sync.URL) != "" {
1594+
syncBranch := strings.TrimSpace(req.Sync.Branch)
1595+
if syncBranch == "" {
1596+
syncBranch = "main"
1597+
}
1598+
newRepo["sync"] = map[string]interface{}{
1599+
"url": strings.TrimSpace(req.Sync.URL),
1600+
"branch": syncBranch,
1601+
}
14531602
}
1603+
14541604
repos = append(repos, newRepo)
14551605
spec["repos"] = repos
14561606

0 commit comments

Comments
 (0)