Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d33a45329c | |||
| 0e3c85f05c | |||
| b24c4dcc86 |
@@ -71,9 +71,6 @@ inputs:
|
||||
required: false
|
||||
default: 'true'
|
||||
system-prompt-file:
|
||||
description: 'Local file with additional system prompt instructions (e.g. security review focus)'
|
||||
required: false
|
||||
default: ''
|
||||
persona:
|
||||
description: 'Built-in persona name (security, architect, docs)'
|
||||
required: false
|
||||
@@ -82,6 +79,9 @@ inputs:
|
||||
description: 'Path to persona JSON file with custom review focus'
|
||||
required: false
|
||||
default: ''
|
||||
description: 'Local file with additional system prompt instructions (e.g. security review focus)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: PR Ready Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize]
|
||||
|
||||
jobs:
|
||||
clear-labels:
|
||||
runs-on: ubuntu-24.04
|
||||
if: contains(github.event.pull_request.labels.*.name, 'self-reviewed')
|
||||
steps:
|
||||
- name: Remove self-reviewed label, reassign to author
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RODIN_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER=${{ github.event.pull_request.number }}
|
||||
AUTHOR=${{ github.event.pull_request.user.login }}
|
||||
SELF_REVIEWED_LABEL_ID=37
|
||||
|
||||
# Remove self-reviewed label if present
|
||||
curl -sS -X DELETE \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://gitea.weiker.me/api/v1/repos/${{ github.repository }}/issues/${PR_NUMBER}/labels/${SELF_REVIEWED_LABEL_ID}" || true
|
||||
|
||||
# Reassign to author
|
||||
curl -sS -X PATCH \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"assignees\": [\"${AUTHOR}\"]}" \
|
||||
"https://gitea.weiker.me/api/v1/repos/${{ github.repository }}/pulls/${PR_NUMBER}"
|
||||
|
||||
echo "Cleared self-reviewed label and reassigned PR #${PR_NUMBER} to ${AUTHOR}"
|
||||
+26
-39
@@ -93,6 +93,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
||||
// Validate persona flags are mutually exclusive
|
||||
if *personaName != "" && *personaFile != "" {
|
||||
slog.Error("--persona and --persona-file are mutually exclusive")
|
||||
@@ -110,12 +111,8 @@ func main() {
|
||||
}
|
||||
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)
|
||||
var err error
|
||||
persona, err = review.LoadPersona(*personaFile)
|
||||
if err != nil {
|
||||
slog.Error("failed to load persona file", "file", *personaFile, "error", err)
|
||||
os.Exit(1)
|
||||
@@ -233,14 +230,34 @@ func main() {
|
||||
// Step 6b: Load additional system prompt if specified
|
||||
additionalPrompt := ""
|
||||
if *systemPromptFile != "" {
|
||||
resolvedPath, err := validateWorkspacePath(*systemPromptFile, "system-prompt-file")
|
||||
workspace := os.Getenv("GITHUB_WORKSPACE")
|
||||
if workspace == "" {
|
||||
workspace, _ = os.Getwd()
|
||||
}
|
||||
absWorkspace, err := filepath.Abs(workspace)
|
||||
if err != nil {
|
||||
slog.Error("invalid system-prompt-file path", "error", err)
|
||||
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)
|
||||
os.Exit(1)
|
||||
}
|
||||
data, err := os.ReadFile(resolvedPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to read system prompt file", "path", *systemPromptFile, "error", err)
|
||||
slog.Error("failed to read system prompt file", "path", promptPath, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
additionalPrompt = string(data)
|
||||
@@ -610,36 +627,6 @@ 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
|
||||
if !strings.HasPrefix(fullPath, absWorkspace+string(filepath.Separator)) && 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
|
||||
resolvedPath, err := filepath.EvalSymlinks(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve %s: %w", pathName, err)
|
||||
}
|
||||
if !strings.HasPrefix(resolvedPath, absWorkspace+string(filepath.Separator)) && resolvedPath != absWorkspace {
|
||||
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 {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.weiker.me/rodin/review-bot/gitea"
|
||||
@@ -46,113 +45,6 @@ func TestValidateReviewerName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspacePath(t *testing.T) {
|
||||
// Create a temp directory as our workspace
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a valid file inside the workspace
|
||||
validFile := filepath.Join(tmpDir, "valid.json")
|
||||
if err := os.WriteFile(validFile, []byte("{}"), 0644); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Create a subdirectory with a file
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
if err := os.MkdirAll(subDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create subdir: %v", err)
|
||||
}
|
||||
nestedFile := filepath.Join(subDir, "nested.json")
|
||||
if err := os.WriteFile(nestedFile, []byte("{}"), 0644); err != nil {
|
||||
t.Fatalf("failed to create nested file: %v", err)
|
||||
}
|
||||
|
||||
// Create a symlink pointing outside the workspace
|
||||
symlinkPath := filepath.Join(tmpDir, "evil-symlink.json")
|
||||
if err := os.Symlink("/etc/passwd", symlinkPath); err != nil {
|
||||
t.Fatalf("failed to create symlink: %v", err)
|
||||
}
|
||||
|
||||
// Save and restore GITHUB_WORKSPACE
|
||||
origWorkspace := os.Getenv("GITHUB_WORKSPACE")
|
||||
defer os.Setenv("GITHUB_WORKSPACE", origWorkspace)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
workspace string
|
||||
path string
|
||||
wantErr bool
|
||||
errMatch string
|
||||
}{
|
||||
{
|
||||
name: "valid relative path",
|
||||
workspace: tmpDir,
|
||||
path: "valid.json",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid nested path",
|
||||
workspace: tmpDir,
|
||||
path: "subdir/nested.json",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "path traversal attempt",
|
||||
workspace: tmpDir,
|
||||
path: "../../../etc/passwd",
|
||||
wantErr: true,
|
||||
errMatch: "resolves outside workspace",
|
||||
},
|
||||
{
|
||||
name: "absolute path gets normalized to relative",
|
||||
workspace: tmpDir,
|
||||
path: "/etc/passwd",
|
||||
wantErr: true,
|
||||
errMatch: "failed to resolve", // filepath.Join strips leading / making it <workspace>/etc/passwd which doesn't exist
|
||||
},
|
||||
{
|
||||
name: "nonexistent file",
|
||||
workspace: tmpDir,
|
||||
path: "nonexistent.json",
|
||||
wantErr: true,
|
||||
errMatch: "failed to resolve",
|
||||
},
|
||||
{
|
||||
name: "symlink escaping workspace",
|
||||
workspace: tmpDir,
|
||||
path: "evil-symlink.json",
|
||||
wantErr: true,
|
||||
errMatch: "symlink resolves outside workspace",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
os.Setenv("GITHUB_WORKSPACE", tc.workspace)
|
||||
resolved, err := validateWorkspacePath(tc.path, "test-file")
|
||||
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected error for %q, got nil", tc.path)
|
||||
} else if tc.errMatch != "" && !strings.Contains(err.Error(), tc.errMatch) {
|
||||
t.Errorf("error %q should contain %q", err.Error(), tc.errMatch)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for %q, got %v", tc.path, err)
|
||||
}
|
||||
if resolved == "" {
|
||||
t.Error("expected non-empty resolved path")
|
||||
}
|
||||
// Verify resolved path is within workspace
|
||||
if !strings.HasPrefix(resolved, tc.workspace) {
|
||||
t.Errorf("resolved path %q not within workspace %q", resolved, tc.workspace)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func makeReview(id int64, login, state string, stale bool, body string) gitea.Review {
|
||||
r := gitea.Review{
|
||||
ID: id,
|
||||
|
||||
@@ -92,9 +92,7 @@ func BuildPersonaSystemPrompt(p *Persona) string {
|
||||
}
|
||||
|
||||
// BuildSystemPromptWithPersona constructs the full system prompt, using either
|
||||
// a persona or the default generic prompt. This is a convenience wrapper that
|
||||
// combines BuildPersonaSystemPrompt (or BuildSystemBase) with patterns and conventions.
|
||||
// It is exported for use by callers who want one-shot prompt assembly.
|
||||
// a persona or the default generic prompt.
|
||||
func BuildSystemPromptWithPersona(persona *Persona, conventions, patterns string) string {
|
||||
var base string
|
||||
if persona != nil {
|
||||
|
||||
Reference in New Issue
Block a user