Compare commits

..

1 Commits

Author SHA1 Message Date
Rodin 3d50707332 feat(persona): add role-based review personas
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 9m32s
CI / review (/anthropic/v1, anthropic--claude-4.6-sonnet, sonnet, anthropic, SONNET_REVIEW_TOKEN) (pull_request) Successful in 10m2s
CI / review (/openai/v1, gpt-5, gpt, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 11m2s
CI / review (/openai/v1, gpt-5, security, openai, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 11m11s
Add persona system for specialized review roles. Each persona defines:
- A specific review focus (security, architecture, documentation)
- Custom system prompt additions
- Personality/tone adjustments

Built-in personas: security, architect, docs
Custom personas: load from JSON via persona-file flag

Includes workspace validation to prevent path traversal attacks.

Closes #51
2026-05-10 08:54:38 -07:00
7 changed files with 105 additions and 53 deletions
+3 -10
View File
@@ -622,28 +622,21 @@ func validateWorkspacePath(path, pathName string) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("failed to resolve workspace path: %w", err) return "", fmt.Errorf("failed to resolve workspace path: %w", err)
} }
// Join and clean the path // Join and clean the path
fullPath := filepath.Join(absWorkspace, path) fullPath := filepath.Join(absWorkspace, path)
fullPath = filepath.Clean(fullPath) fullPath = filepath.Clean(fullPath)
// Check path is within workspace
// Check path is within workspace using filepath.Rel (more robust than HasPrefix) if !strings.HasPrefix(fullPath, absWorkspace+string(filepath.Separator)) && fullPath != absWorkspace {
rel, err := filepath.Rel(absWorkspace, fullPath)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("%s resolves outside workspace: path=%s workspace=%s", pathName, fullPath, absWorkspace) return "", fmt.Errorf("%s resolves outside workspace: path=%s workspace=%s", pathName, fullPath, absWorkspace)
} }
// Resolve symlinks and re-validate to prevent symlink traversal // Resolve symlinks and re-validate to prevent symlink traversal
resolvedPath, err := filepath.EvalSymlinks(fullPath) resolvedPath, err := filepath.EvalSymlinks(fullPath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to resolve %s: %w", pathName, err) return "", fmt.Errorf("failed to resolve %s: %w", pathName, err)
} }
if !strings.HasPrefix(resolvedPath, absWorkspace+string(filepath.Separator)) && resolvedPath != absWorkspace {
relResolved, err := filepath.Rel(absWorkspace, resolvedPath)
if err != nil || strings.HasPrefix(relResolved, "..") {
return "", fmt.Errorf("%s symlink resolves outside workspace: resolved=%s workspace=%s", pathName, resolvedPath, absWorkspace) return "", fmt.Errorf("%s symlink resolves outside workspace: resolved=%s workspace=%s", pathName, resolvedPath, absWorkspace)
} }
return resolvedPath, nil return resolvedPath, nil
} }
+1 -1
View File
@@ -6,8 +6,8 @@ import (
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"path/filepath"
"testing" "testing"
"gitea.weiker.me/rodin/review-bot/gitea" "gitea.weiker.me/rodin/review-bot/gitea"
+30 -7
View File
@@ -318,13 +318,36 @@ Design says header shows "persona display name" but sentinel uses "reviewer-name
When persona is used, `display_name` takes precedence for the header title, but `reviewer-name` (CLI flag) is still used for the sentinel. When persona is used, `display_name` takes precedence for the header title, but `reviewer-name` (CLI flag) is still used for the sentinel.
## Design Revision: YAML with gopkg.in/yaml.v3 ## Design Revision: JSON Instead of YAML
**Decision:** Add `gopkg.in/yaml.v3` as a dependency. **Reason:** Project convention is "Go standard library only — no external dependencies."
YAML is preferred over JSON for persona files because: YAML requires `gopkg.in/yaml.v3` or similar. To maintain zero dependencies, persona files will use JSON instead.
- Multi-line strings are cleaner (no escaping quotes in identity/focus text)
- Comments are supported for documentation
- More human-readable for complex persona definitions
The implementation supports both YAML (`.yaml`, `.yml`) and JSON (`.json`) for backwards compatibility, with YAML as the default for built-in personas. ### Updated Persona File Format
```json
{
"name": "security",
"display_name": "Security Specialist",
"model_preference": "opus",
"identity": "You are a security specialist reviewing code for vulnerabilities.\nYour expertise: OWASP Top 10, injection attacks, auth/authz, secrets management.",
"focus": [
"Injection attacks (SQL, command, path traversal, template)",
"Authentication and authorization gaps",
"Secrets exposure (hardcoded credentials, tokens in logs)"
],
"ignore": [
"Code style and naming conventions",
"Performance (unless security-related)",
"Documentation"
],
"severity": {
"major": "Privilege escalation, information disclosure, DoS",
"minor": "Missing rate limiting, verbose errors",
"nit": "Theoretical risk with low exploitability"
}
}
```
This maintains all the same fields but uses JSON encoding, which Go handles natively via `encoding/json`.
+33 -3
View File
@@ -7,7 +7,39 @@ import (
// FormatMarkdown formats a ReviewResult into the markdown body for a Gitea review. // FormatMarkdown formats a ReviewResult into the markdown body for a Gitea review.
func FormatMarkdown(result *ReviewResult, reviewerName string) string { func FormatMarkdown(result *ReviewResult, reviewerName string) string {
return FormatMarkdownWithDisplay(result, reviewerName, reviewerName) var sb strings.Builder
if reviewerName != "" {
title := strings.ToUpper(reviewerName[:1]) + reviewerName[1:]
sb.WriteString(fmt.Sprintf("# %s Review\n\n", title))
}
sb.WriteString("## Summary\n\n")
sb.WriteString(result.Summary)
sb.WriteString("\n\n")
if len(result.Findings) > 0 {
sb.WriteString("## Findings\n\n")
sb.WriteString("| # | Severity | File | Line | Finding |\n")
sb.WriteString("|---|----------|------|------|--------|\n")
for i, f := range result.Findings {
sb.WriteString(fmt.Sprintf("| %d | [%s] | `%s` | %d | %s |\n",
i+1, f.Severity, f.File, f.Line, f.Finding))
}
sb.WriteString("\n")
}
sb.WriteString("## Recommendation\n\n")
sb.WriteString(fmt.Sprintf("**%s** — %s\n", result.Verdict, result.Recommendation))
if reviewerName != "" {
sb.WriteString(fmt.Sprintf("\n---\n*Review by %s*\n", reviewerName))
// Hidden sentinel for identifying this bot's reviews during cleanup
sb.WriteString(fmt.Sprintf("\n<!-- review-bot:%s -->\n", reviewerName))
}
return sb.String()
} }
// GiteaEvent converts the verdict to the Gitea API event string. // GiteaEvent converts the verdict to the Gitea API event string.
@@ -23,8 +55,6 @@ func GiteaEvent(verdict string) string {
} }
// FormatMarkdownWithDisplay formats a ReviewResult with separate display name and sentinel name. // FormatMarkdownWithDisplay formats a ReviewResult with separate display name and sentinel name.
// Note: displayName is not HTML-escaped as Gitea sanitizes rendered Markdown.
// Persona display names are controlled by repo owners (trusted input).
// displayName is used for the header title, sentinelName is used for the cleanup sentinel. // displayName is used for the header title, sentinelName is used for the cleanup sentinel.
// If displayName is empty, sentinelName is used for both. // If displayName is empty, sentinelName is used for both.
func FormatMarkdownWithDisplay(result *ReviewResult, displayName, sentinelName string) string { func FormatMarkdownWithDisplay(result *ReviewResult, displayName, sentinelName string) string {
+1 -1
View File
@@ -48,7 +48,7 @@ func LoadPersona(path string) (*Persona, error) {
// Returns an error if the persona doesn't exist. // Returns an error if the persona doesn't exist.
func LoadBuiltinPersona(name string) (*Persona, error) { func LoadBuiltinPersona(name string) (*Persona, error) {
filename := name + ".yaml" filename := name + ".yaml"
data, err := embeddedPersonas.ReadFile("personas/" + filename) // embed.FS paths use forward slashes per io/fs spec data, err := embeddedPersonas.ReadFile(filepath.Join("personas", filename))
if err != nil { if err != nil {
available := ListBuiltinPersonas() available := ListBuiltinPersonas()
return nil, fmt.Errorf("unknown built-in persona %q (available: %s)", name, strings.Join(available, ", ")) return nil, fmt.Errorf("unknown built-in persona %q (available: %s)", name, strings.Join(available, ", "))
+19 -5
View File
@@ -50,7 +50,7 @@ func BuildPersonaSystemPrompt(p *Persona) string {
sb.WriteString("\n") sb.WriteString("\n")
} }
// Output format instructions (shared schema from prompt.go) // Output format instructions (same as base, but with persona context)
sb.WriteString("## Review Instructions\n\n") sb.WriteString("## Review Instructions\n\n")
sb.WriteString("CONTEXT:\n") sb.WriteString("CONTEXT:\n")
sb.WriteString("- You will receive the full content of modified files for reference, followed by the diff showing what changed.\n") sb.WriteString("- You will receive the full content of modified files for reference, followed by the diff showing what changed.\n")
@@ -61,10 +61,24 @@ func BuildPersonaSystemPrompt(p *Persona) string {
sb.WriteString("2. Consider the CI status — if CI has failed, that is an automatic REQUEST_CHANGES regardless of code quality.\n") sb.WriteString("2. Consider the CI status — if CI has failed, that is an automatic REQUEST_CHANGES regardless of code quality.\n")
sb.WriteString("3. Output your review as structured JSON (and ONLY JSON, no markdown fences or other text).\n\n") sb.WriteString("3. Output your review as structured JSON (and ONLY JSON, no markdown fences or other text).\n\n")
sb.WriteString("Output format:\n") sb.WriteString("Output format:\n")
sb.WriteString(outputSchemaJSON) sb.WriteString("{\n")
sb.WriteString("\n\n") sb.WriteString(" \"verdict\": \"APPROVE\" or \"REQUEST_CHANGES\",\n")
sb.WriteString(verdictRules) sb.WriteString(" \"summary\": \"Brief overall assessment (1-3 sentences)\",\n")
sb.WriteString("\n- Only report findings within your focus areas. Ignore everything else.\n") sb.WriteString(" \"findings\": [\n")
sb.WriteString(" {\n")
sb.WriteString(" \"severity\": \"MAJOR\" or \"MINOR\" or \"NIT\",\n")
sb.WriteString(" \"file\": \"path/to/file\",\n")
sb.WriteString(" \"line\": <line number from the diff>,\n")
sb.WriteString(" \"finding\": \"Description of the issue\"\n")
sb.WriteString(" }\n")
sb.WriteString(" ],\n")
sb.WriteString(" \"recommendation\": \"Full recommendation text explaining your verdict\"\n")
sb.WriteString("}\n\n")
sb.WriteString("Rules:\n")
sb.WriteString("- If there are any MAJOR findings → verdict must be REQUEST_CHANGES\n")
sb.WriteString("- If there are no MAJOR findings → verdict should be APPROVE\n")
sb.WriteString("- If CI has failed → verdict must be REQUEST_CHANGES with a finding noting the CI failure\n")
sb.WriteString("- Only report findings within your focus areas. Ignore everything else.\n")
sb.WriteString("- Line numbers should reference the new file line numbers from the diff headers.\n") sb.WriteString("- Line numbers should reference the new file line numbers from the diff headers.\n")
sb.WriteString("- If the diff has no changes relevant to your focus areas, APPROVE with no findings.\n") sb.WriteString("- If the diff has no changes relevant to your focus areas, APPROVE with no findings.\n")
+18 -26
View File
@@ -7,28 +7,6 @@ import (
"strings" "strings"
) )
// outputSchemaJSON is the shared JSON output format specification used by both
// the generic reviewer and persona-based reviewers.
const outputSchemaJSON = `{
"verdict": "APPROVE" or "REQUEST_CHANGES",
"summary": "Brief overall assessment (1-3 sentences)",
"findings": [
{
"severity": "MAJOR" or "MINOR" or "NIT",
"file": "path/to/file",
"line": <line number from the diff>,
"finding": "Description of the issue"
}
],
"recommendation": "Full recommendation text explaining your verdict"
}`
// verdictRules is the shared verdict determination rules.
const verdictRules = `Rules:
- If there are any MAJOR findings → verdict must be REQUEST_CHANGES
- If there are no MAJOR findings → verdict should be APPROVE
- If CI has failed → verdict must be REQUEST_CHANGES with a finding noting the CI failure`
// BuildSystemBase returns the core system prompt instructions without // BuildSystemBase returns the core system prompt instructions without
// patterns or conventions. Used by the budget package to separate // patterns or conventions. Used by the budget package to separate
// trimmable from non-trimmable content. // trimmable from non-trimmable content.
@@ -45,10 +23,24 @@ func BuildSystemBase() string {
sb.WriteString("2. Consider the CI status — if CI has failed, that is an automatic REQUEST_CHANGES regardless of code quality.\n") sb.WriteString("2. Consider the CI status — if CI has failed, that is an automatic REQUEST_CHANGES regardless of code quality.\n")
sb.WriteString("3. Output your review as structured JSON (and ONLY JSON, no markdown fences or other text).\n\n") sb.WriteString("3. Output your review as structured JSON (and ONLY JSON, no markdown fences or other text).\n\n")
sb.WriteString("Output format:\n") sb.WriteString("Output format:\n")
sb.WriteString(outputSchemaJSON) sb.WriteString("{\n")
sb.WriteString("\n\n") sb.WriteString(" \"verdict\": \"APPROVE\" or \"REQUEST_CHANGES\",\n")
sb.WriteString(verdictRules) sb.WriteString(" \"summary\": \"Brief overall assessment (1-3 sentences)\",\n")
sb.WriteString("\n- Be thorough but fair. Don't nitpick style unless it impacts readability significantly.\n") sb.WriteString(" \"findings\": [\n")
sb.WriteString(" {\n")
sb.WriteString(" \"severity\": \"MAJOR\" or \"MINOR\" or \"NIT\",\n")
sb.WriteString(" \"file\": \"path/to/file\",\n")
sb.WriteString(" \"line\": <line number from the diff>,\n")
sb.WriteString(" \"finding\": \"Description of the issue\"\n")
sb.WriteString(" }\n")
sb.WriteString(" ],\n")
sb.WriteString(" \"recommendation\": \"Full recommendation text explaining your verdict\"\n")
sb.WriteString("}\n\n")
sb.WriteString("Rules:\n")
sb.WriteString("- If there are any MAJOR findings → verdict must be REQUEST_CHANGES\n")
sb.WriteString("- If there are no MAJOR findings → verdict should be APPROVE\n")
sb.WriteString("- If CI has failed → verdict must be REQUEST_CHANGES with a finding noting the CI failure\n")
sb.WriteString("- Be thorough but fair. Don't nitpick style unless it impacts readability significantly.\n")
sb.WriteString("- Line numbers should reference the new file line numbers from the diff headers.\n") sb.WriteString("- Line numbers should reference the new file line numbers from the diff headers.\n")
sb.WriteString("- If the diff is empty or trivial (only formatting/whitespace), APPROVE with no findings.\n") sb.WriteString("- If the diff is empty or trivial (only formatting/whitespace), APPROVE with no findings.\n")