feat(persona): add role-based review personas
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 9m31s
CI / review (/anthropic/v1, anthropic--claude-4.6-sonnet, sonnet, anthropic, SONNET_REVIEW_TOKEN) (pull_request) Successful in 10m3s
CI / review (/openai/v1, gpt-5, gpt, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 11m30s
CI / review (/openai/v1, gpt-5, security, openai, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 10m56s
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 9m31s
CI / review (/anthropic/v1, anthropic--claude-4.6-sonnet, sonnet, anthropic, SONNET_REVIEW_TOKEN) (pull_request) Successful in 10m3s
CI / review (/openai/v1, gpt-5, gpt, openai, GPT_REVIEW_TOKEN) (pull_request) Successful in 11m30s
CI / review (/openai/v1, gpt-5, security, openai, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 10m56s
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
This commit is contained in:
+85
-25
@@ -70,6 +70,8 @@ func main() {
|
||||
llmTemp := flag.Float64("llm-temperature", envOrDefaultFloat("LLM_TEMPERATURE", 0), "LLM temperature (0 = server default)")
|
||||
llmTimeout := flag.Int("llm-timeout", envOrDefaultInt("LLM_TIMEOUT", 300), "LLM request timeout in seconds (default 300)")
|
||||
llmProvider := flag.String("llm-provider", envOrDefault("LLM_PROVIDER", "openai"), "LLM API provider: openai or anthropic")
|
||||
personaName := flag.String("persona", envOrDefault("PERSONA", ""), "Built-in persona name (security, architect, docs)")
|
||||
personaFile := flag.String("persona-file", envOrDefault("PERSONA_FILE", ""), "Path to persona JSON file")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
@@ -91,6 +93,36 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Validate persona flags are mutually exclusive
|
||||
if *personaName != "" && *personaFile != "" {
|
||||
slog.Error("--persona and --persona-file are mutually exclusive")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Load persona if specified
|
||||
var persona *review.Persona
|
||||
if *personaName != "" {
|
||||
var err error
|
||||
persona, err = review.LoadBuiltinPersona(*personaName)
|
||||
if err != nil {
|
||||
slog.Error("failed to load persona", "persona", *personaName, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("loaded built-in persona", "persona", persona.Name, "display", persona.DisplayName)
|
||||
} else if *personaFile != "" {
|
||||
resolvedPath, err := validateWorkspacePath(*personaFile, "persona-file")
|
||||
if err != nil {
|
||||
slog.Error("invalid persona-file path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
persona, err = review.LoadPersona(resolvedPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to load persona file", "file", *personaFile, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("loaded persona from file", "file", *personaFile, "persona", persona.Name)
|
||||
}
|
||||
|
||||
// Validate reviewer-name: only safe characters allowed in sentinel
|
||||
if err := validateReviewerName(*reviewerName); err != nil {
|
||||
slog.Error("invalid reviewer name", "error", err)
|
||||
@@ -201,34 +233,14 @@ func main() {
|
||||
// Step 6b: Load additional system prompt if specified
|
||||
additionalPrompt := ""
|
||||
if *systemPromptFile != "" {
|
||||
workspace := os.Getenv("GITHUB_WORKSPACE")
|
||||
if workspace == "" {
|
||||
workspace, _ = os.Getwd()
|
||||
}
|
||||
absWorkspace, err := filepath.Abs(workspace)
|
||||
resolvedPath, err := validateWorkspacePath(*systemPromptFile, "system-prompt-file")
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve workspace path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
promptPath := filepath.Join(absWorkspace, *systemPromptFile)
|
||||
promptPath = filepath.Clean(promptPath)
|
||||
if !strings.HasPrefix(promptPath, absWorkspace+string(filepath.Separator)) && promptPath != absWorkspace {
|
||||
slog.Error("system-prompt-file resolves outside workspace", "path", promptPath, "workspace", absWorkspace)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Resolve symlinks and re-validate to prevent symlink traversal
|
||||
resolvedPath, err := filepath.EvalSymlinks(promptPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to resolve system prompt file", "path", promptPath, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if !strings.HasPrefix(resolvedPath, absWorkspace+string(filepath.Separator)) && resolvedPath != absWorkspace {
|
||||
slog.Error("system-prompt-file symlink resolves outside workspace", "resolved", resolvedPath, "workspace", absWorkspace)
|
||||
slog.Error("invalid system-prompt-file path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
data, err := os.ReadFile(resolvedPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to read system prompt file", "path", promptPath, "error", err)
|
||||
slog.Error("failed to read system prompt file", "path", *systemPromptFile, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
additionalPrompt = string(data)
|
||||
@@ -236,7 +248,13 @@ func main() {
|
||||
}
|
||||
|
||||
// Step 7: Budget-aware prompt assembly
|
||||
systemBase := review.BuildSystemBase()
|
||||
var systemBase string
|
||||
if persona != nil {
|
||||
systemBase = review.BuildPersonaSystemPrompt(persona)
|
||||
slog.Debug("using persona system prompt", "persona", persona.Name)
|
||||
} else {
|
||||
systemBase = review.BuildSystemBase()
|
||||
}
|
||||
if additionalPrompt != "" {
|
||||
systemBase += "\n\n## Additional Review Instructions\n\n" + additionalPrompt
|
||||
}
|
||||
@@ -293,7 +311,12 @@ func main() {
|
||||
slog.Info("review parsed", "verdict", result.Verdict, "findings", len(result.Findings))
|
||||
|
||||
// Step 10: Format and post review
|
||||
reviewBody := review.FormatMarkdown(result, *reviewerName)
|
||||
var reviewBody string
|
||||
if persona != nil && persona.DisplayName != "" {
|
||||
reviewBody = review.FormatMarkdownWithDisplay(result, persona.DisplayName, *reviewerName)
|
||||
} else {
|
||||
reviewBody = review.FormatMarkdown(result, *reviewerName)
|
||||
}
|
||||
|
||||
// Add commit footer so readers know which commit was evaluated
|
||||
if pr.Head.Sha != "" {
|
||||
@@ -587,6 +610,43 @@ func validateReviewerName(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateWorkspacePath ensures a file path is within the workspace and resolves
|
||||
// symlinks to prevent traversal attacks. Returns the resolved absolute path or
|
||||
// an error if the path is outside the workspace.
|
||||
func validateWorkspacePath(path, pathName string) (string, error) {
|
||||
workspace := os.Getenv("GITHUB_WORKSPACE")
|
||||
if workspace == "" {
|
||||
workspace, _ = os.Getwd()
|
||||
}
|
||||
absWorkspace, err := filepath.Abs(workspace)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve workspace path: %w", err)
|
||||
}
|
||||
|
||||
// Join and clean the path
|
||||
fullPath := filepath.Join(absWorkspace, path)
|
||||
fullPath = filepath.Clean(fullPath)
|
||||
|
||||
// Check path is within workspace using filepath.Rel (more robust than HasPrefix)
|
||||
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)
|
||||
}
|
||||
|
||||
// Resolve symlinks and re-validate to prevent symlink traversal
|
||||
resolvedPath, err := filepath.EvalSymlinks(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve %s: %w", pathName, err)
|
||||
}
|
||||
|
||||
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 resolvedPath, nil
|
||||
}
|
||||
|
||||
// buildSupersededBody creates the body for a superseded review: struck-through banner
|
||||
// with collapsed original content and the commit it was evaluated against.
|
||||
func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string) string {
|
||||
|
||||
Reference in New Issue
Block a user