fix: symlink traversal + worst-wins pre-check + user scoping
CI / test (pull_request) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 20s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m11s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m16s

Security (MAJOR):
- Add filepath.EvalSymlinks after Clean for system-prompt-file
- Re-validate resolved path is still within workspace
- Prevents symlink → /etc/shadow exfiltration via malicious repo

Worst-wins:
- Check BEFORE posting (not after) — no delete+repost dance
- Identify sibling bots by <!-- review-bot: prefix in body
- Only escalates for bot reviews, not human REQUEST_CHANGES
- If sibling bot has REQUEST_CHANGES and we would APPROVE → post
  REQUEST_CHANGES instead

Addresses security review finding #1 (MAJOR) and sonnet finding #1.
This commit is contained in:
Rodin
2026-05-01 21:16:16 -07:00
parent 687005d982
commit 436e6a8824
3 changed files with 206 additions and 21 deletions
+55 -20
View File
@@ -168,7 +168,15 @@ func main() {
if !strings.HasPrefix(promptPath, absWorkspace+string(filepath.Separator)) && promptPath != absWorkspace {
log.Fatalf("system-prompt-file resolves outside workspace (got %q, workspace %q)", promptPath, absWorkspace)
}
data, err := os.ReadFile(promptPath)
// Resolve symlinks and re-validate to prevent symlink traversal
resolvedPath, err := filepath.EvalSymlinks(promptPath)
if err != nil {
log.Fatalf("Failed to resolve system prompt file %q: %v", promptPath, err)
}
if !strings.HasPrefix(resolvedPath, absWorkspace+string(filepath.Separator)) && resolvedPath != absWorkspace {
log.Fatalf("system-prompt-file symlink resolves outside workspace (got %q, workspace %q)", resolvedPath, absWorkspace)
}
data, err := os.ReadFile(resolvedPath)
if err != nil {
log.Fatalf("Failed to read system prompt file %q: %v", promptPath, err)
}
@@ -226,25 +234,11 @@ func main() {
return
}
// Worst-wins: if we're about to APPROVE but a sibling review from the same
// user already has REQUEST_CHANGES, post as REQUEST_CHANGES too so we don't
// override the blocking state.
if event == "APPROVED" && *reviewerName != "" {
existing, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber)
if err == nil {
for _, r := range existing {
if !r.Stale && r.State == "REQUEST_CHANGES" {
// Check it's from the same user (same token) but a different role
sentinelCheck := fmt.Sprintf("<!-- review-bot:%s -->", *reviewerName)
if !strings.Contains(r.Body, sentinelCheck) {
log.Printf("Sibling review %d has REQUEST_CHANGES; escalating to REQUEST_CHANGES", r.ID)
event = "REQUEST_CHANGES"
break
}
}
}
}
// Validate reviewer-name: only safe characters allowed in sentinel
if err := validateReviewerName(*reviewerName); err != nil {
log.Fatalf("%v", err)
}
sentinel := fmt.Sprintf("<!-- review-bot:%s -->", *reviewerName)
log.Printf("Posting review (event=%s)...", event)
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody)
@@ -254,7 +248,6 @@ func main() {
log.Printf("Review posted (id=%d, user=%s)", posted.ID, posted.User.Login)
// Delete stale reviews from this bot using sentinel matching
sentinel := fmt.Sprintf("<!-- review-bot:%s -->", *reviewerName)
if *updateExisting && *reviewerName != "" {
reviews, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber)
if err != nil {
@@ -270,6 +263,22 @@ func main() {
}
}
// Worst-wins: if we posted APPROVE but a sibling review from the
// same user (same token, different role) has REQUEST_CHANGES,
// delete ours and re-post as REQUEST_CHANGES to maintain the block.
if event == "APPROVED" && shouldEscalate(reviews, posted.ID, posted.User.Login, sentinel) {
log.Printf("Sibling review has REQUEST_CHANGES; escalating")
if err := giteaClient.DeleteReview(ctx, owner, repoName, prNumber, posted.ID); err != nil {
log.Printf("Warning: could not delete review for escalation: %v", err)
} else {
_, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, "REQUEST_CHANGES", reviewBody)
if err != nil {
log.Printf("Warning: could not re-post as REQUEST_CHANGES: %v", err)
} else {
log.Printf("Review escalated to REQUEST_CHANGES")
}
}
}
}
}
}
@@ -413,3 +422,29 @@ func envOrDefaultBool(key string, defaultVal bool) bool {
}
return v == "true" || v == "1" || v == "yes"
}
// validateReviewerName checks that the name contains only safe characters
// for embedding in an HTML comment sentinel ([a-zA-Z0-9_-]).
func validateReviewerName(name string) error {
if name == "" {
return nil
}
for _, ch := range name {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
return fmt.Errorf("reviewer-name must contain only [a-zA-Z0-9_-] (got %q)", name)
}
}
return nil
}
// shouldEscalate checks if the current APPROVED review should be escalated
// to REQUEST_CHANGES because a sibling bot review (same user, different role)
// already has REQUEST_CHANGES.
func shouldEscalate(reviews []gitea.Review, postedID int64, postedLogin, ownSentinel string) bool {
for _, r := range reviews {
if r.ID != postedID && !r.Stale && r.User.Login == postedLogin && r.State == "REQUEST_CHANGES" && strings.Contains(r.Body, "<!-- review-bot:") && !strings.Contains(r.Body, ownSentinel) {
return true
}
}
return false
}