Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ description: 'Summarize, review and suggest improvements for pull requests'
branding:
icon: 'award'
color: 'green'
inputs:
artifact_path:
description: 'Path to a CI artifact file (relative to GITHUB_WORKSPACE or absolute) to include as extra context in PR analysis. Leave empty to disable artifact injection.'
required: false
default: ''
artifact_type:
description: 'Parser type for the artifact content. Supported values: generic, terraform_plan, test_report'
required: false
default: 'generic'
runs:
using: 'docker'
image: 'Dockerfile.github_action_dockerhub'
env:
ARTIFACT_PATH: ${{ inputs.artifact_path }}
ARTIFACT_TYPE: ${{ inputs.artifact_type }}
1 change: 1 addition & 0 deletions github_action/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
#!/bin/bash
set -e
python /app/pr_agent/servers/github_action_runner.py
132 changes: 132 additions & 0 deletions pr_agent/algo/artifacts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import os
from pathlib import Path
from typing import Optional

from pr_agent.config_loader import get_settings
from pr_agent.log import get_logger


ARTIFACT_PARSERS = {}


def register_parser(artifact_type: str):
def decorator(func):
ARTIFACT_PARSERS[artifact_type] = func
return func
return decorator


@register_parser("generic")
def parse_generic(content: str, label: str) -> str:
header = f"CI Artifact: {label}" if label else "CI Artifact"
return (
f"{header}\n"
f"=====\n"
f"{content}\n"
f"=====\n"
f"Consider this artifact as additional context when analyzing the PR. "
f"It was produced by a prior CI step."
)


@register_parser("terraform_plan")
def parse_terraform_plan(content: str, label: str) -> str:
header = f"Terraform Plan Output: {label}" if label else "Terraform Plan Output"
return (
f"{header}\n"
f"=====\n"
f"{content}\n"
f"=====\n"
f"This is the Terraform execution plan for the infrastructure changes in this PR. "
f"Use it to verify that the code changes produce the intended infrastructure modifications. "
f"Flag any unexpected resource deletions or risky changes."
)


@register_parser("test_report")
def parse_test_report(content: str, label: str) -> str:
header = f"Test Results: {label}" if label else "Test Results"
return (
f"{header}\n"
f"=====\n"
f"{content}\n"
f"=====\n"
f"These are the test results from the CI pipeline for this PR. "
f"If there are failures, correlate them with the code changes in the diff. "
f"Note any tests that are newly failing."
)


def resolve_artifact_path(path: str) -> Optional[Path]:
if not path:
return None

artifact_path = Path(path)
if artifact_path.is_absolute():
return artifact_path if artifact_path.is_file() else None

workspace = os.environ.get("GITHUB_WORKSPACE", "")
if workspace:
resolved = Path(workspace) / artifact_path
if resolved.is_file():
return resolved

resolved = artifact_path.resolve()
if resolved.is_file():
return resolved

Comment on lines +60 to +77

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Artifact path can escape workspace 📎 Requirement gap ⛨ Security

resolve_artifact_path() accepts absolute paths and falls back to resolving relative paths against
the current working directory, allowing ARTIFACT_PATH to escape GITHUB_WORKSPACE (including ..
traversal) and read arbitrary local files instead of workspace-local artifacts. Because the resolved
file content is injected into tool extra_instructions and rendered into LLM prompts, this violates
the workspace-relative artifact requirement and can leak unintended sensitive files to the model
provider.
Agent Prompt
## Issue description
`resolve_artifact_path()` currently permits resolving artifact paths outside `GITHUB_WORKSPACE` by allowing absolute paths and by resolving relative paths against the container’s current working directory. Because the resolved file contents are injected into tool `extra_instructions` and rendered into prompts sent to the model, this breaks the workspace-relative artifact contract and creates an arbitrary local file read → prompt exfiltration risk.

## Issue Context
Compliance requires artifact reading to be `GITHUB_WORKSPACE`-relative and effectively confined to that directory before injecting content into `extra_instructions`. Today, `ARTIFACT_PATH` is sourced from environment variables in the GitHub Action runner, the artifact text is loaded and injected into `extra_instructions` for tools, and those instructions are rendered into prompts and sent to the model via `chat_completion`; the runner also has access to sensitive data (e.g., `GITHUB_TOKEN`/`OPENAI_KEY`), so allowing artifacts to point outside the workspace can leak unintended local files.

## Fix Focus Areas
- pr_agent/algo/artifacts.py[60-78]
- pr_agent/servers/github_action_runner.py[110-139]
- pr_agent/settings/configuration.toml[371-383]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

return None


def _read_and_truncate(path: Path, max_size: int) -> str:
try:
content = path.read_text(encoding="utf-8", errors="replace")
except (OSError, IOError) as e:
get_logger().warning(f"Failed to read artifact file {path}: {e}")
return ""

if len(content) > max_size:
content = content[:max_size] + "\n\n[... content truncated due to size limit ...]"
return content


def load_artifact_content(tool_key: str) -> str:
try:
artifacts_settings = get_settings().get("ARTIFACTS", {})
except AttributeError:
return ""

if not artifacts_settings:
return ""

enabled = artifacts_settings.get("enable", False)
if not enabled:
return ""

artifact_path_str = artifacts_settings.get("artifact_path", "")
if not artifact_path_str:
return ""

target_tools = artifacts_settings.get("target_tools",
["pr_reviewer", "pr_description", "pr_code_suggestions"])
if tool_key not in target_tools:
return ""

artifact_path = resolve_artifact_path(artifact_path_str)
if not artifact_path:
get_logger().warning(
f"Artifact file not found: '{artifact_path_str}' "
f"(GITHUB_WORKSPACE={os.environ.get('GITHUB_WORKSPACE', 'not set')})"
)
return ""

max_size = int(artifacts_settings.get("max_artifact_size", 50000))
content = _read_and_truncate(artifact_path, max_size)
if not content:
return ""

artifact_type = artifacts_settings.get("artifact_type", "generic")
label = artifacts_settings.get("artifact_label", "") or artifact_path.name

parser = ARTIFACT_PARSERS.get(artifact_type, ARTIFACT_PARSERS["generic"])
return parser(content, label)
77 changes: 77 additions & 0 deletions pr_agent/servers/github_action_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,40 @@ async def run_action():
setting.extra_instructions = updated_instructions
except Exception as e:
get_logger().info(f"github action: failed to apply language-specific instructions: {e}")

# Inject artifact content into extra_instructions for configured tools
try:
ARTIFACT_PATH = os.environ.get('ARTIFACT_PATH') or os.environ.get('PR_AGENT_ARTIFACT_PATH')
ARTIFACT_TYPE = os.environ.get('ARTIFACT_TYPE') or os.environ.get('PR_AGENT_ARTIFACT_TYPE')
if ARTIFACT_PATH:
get_settings().set("ARTIFACTS.ENABLE", True)
get_settings().set("ARTIFACTS.ARTIFACT_PATH", ARTIFACT_PATH)
if ARTIFACT_TYPE:
get_settings().set("ARTIFACTS.ARTIFACT_TYPE", ARTIFACT_TYPE)

artifacts_enabled = get_settings().get("ARTIFACTS.ENABLE", False)
if is_true(artifacts_enabled):
from pr_agent.algo.artifacts import load_artifact_content

get_logger().info("Artifact injection enabled, processing artifacts")
for key in get_settings():
setting = get_settings().get(key)
if str(type(setting)) == "<class 'dynaconf.utils.boxing.DynaBox'>":
if key.lower() in ['pr_description', 'pr_code_suggestions', 'pr_reviewer']:
artifact_text = load_artifact_content(key.lower())
if artifact_text:
if hasattr(setting, 'extra_instructions'):
extra_instructions = setting.extra_instructions
separator = "\n======\n\nCI Artifact Context:\n"
updated_instructions = (
str(extra_instructions) + separator + artifact_text
if extra_instructions else artifact_text
)
setting.extra_instructions = updated_instructions
get_logger().info(f"Injected artifact context into {key}")
except Exception as e:
get_logger().info(f"github action: failed to process artifacts: {e}")

# Handle pull request opened event
if GITHUB_EVENT_NAME == "pull_request" or GITHUB_EVENT_NAME == "pull_request_target":
action = event_payload.get("action")
Expand Down Expand Up @@ -189,6 +223,49 @@ async def run_action():
else:
await PRAgent().handle_request(url, body)

# Handle workflow_run event (triggered after another workflow completes, e.g. after a terraform plan)
elif GITHUB_EVENT_NAME == "workflow_run":
workflow_run = event_payload.get("workflow_run", {})
if workflow_run.get("event") != "pull_request":
get_logger().info(f"Skipping workflow_run: originating event is '{workflow_run.get('event')}', not 'pull_request'")
return

pull_requests = workflow_run.get("pull_requests", [])
if not pull_requests:
get_logger().info("Skipping workflow_run: no pull_requests found in payload (fork PRs are not supported)")
return

pr_url = pull_requests[0].get("url")
if not pr_url:
get_logger().info("Skipping workflow_run: pull_requests[0] has no url")
return

try:
apply_repo_settings(pr_url)
except Exception as e:
get_logger().info(f"github action: failed to apply repo settings for workflow_run: {e}")

auto_review = get_setting_or_env("GITHUB_ACTION.AUTO_REVIEW", None)
if auto_review is None:
auto_review = get_setting_or_env("GITHUB_ACTION_CONFIG.AUTO_REVIEW", None)
auto_describe = get_setting_or_env("GITHUB_ACTION.AUTO_DESCRIBE", None)
if auto_describe is None:
auto_describe = get_setting_or_env("GITHUB_ACTION_CONFIG.AUTO_DESCRIBE", None)
auto_improve = get_setting_or_env("GITHUB_ACTION.AUTO_IMPROVE", None)
if auto_improve is None:
auto_improve = get_setting_or_env("GITHUB_ACTION_CONFIG.AUTO_IMPROVE", None)

get_settings().config.is_auto_command = True
get_settings().pr_description.final_update_message = False
get_logger().info(f"Running auto actions for workflow_run: auto_describe={auto_describe}, auto_review={auto_review}, auto_improve={auto_improve}")

if auto_describe is None or is_true(auto_describe):
await PRDescription(pr_url).run()
if auto_review is None or is_true(auto_review):
await PRReviewer(pr_url).run()
if auto_improve is None or is_true(auto_improve):
await PRCodeSuggestions(pr_url).run()


if __name__ == '__main__':
asyncio.run(run_action())
14 changes: 14 additions & 0 deletions pr_agent/settings/configuration.toml
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,20 @@ extra_instructions = "" # public - extra instructions to the auto best practices
content = ""
max_patterns = 5 # max number of patterns to be detected

[artifacts]
# Enable artifact injection into tool prompts (off by default; auto-enabled when artifact_path input is set)
enable = false
# File path to the artifact (relative to GITHUB_WORKSPACE, or absolute)
artifact_path = ""
# Parser type: "generic", "terraform_plan", "test_report"
artifact_type = "generic"
# Label shown to the AI — defaults to the filename when empty
artifact_label = ""
# Which tools receive artifact context
target_tools = ["pr_reviewer", "pr_description", "pr_code_suggestions"]
# Max artifact size in characters (content is truncated if exceeded)
max_artifact_size = 50000

[azure_devops]
default_comment_status = "closed"

Expand Down
Loading
Loading