Turns structured scan findings (scan_results.json) into reviewed Pull Requests.
The agent never merges anything itself — it only opens PRs for a human to review.
- Loads findings from a JSON file.
- For each finding:
- Skips it if an open PR already references that finding ID (idempotency).
- Reads the affected file.
- Calls Claude with a structured prompt to produce a full corrected file.
- Validates the fix (syntax check — Python via
ast, JSON viajson, YAML viaPyYAML, JS/TS vianode --checkif Node is installed; optionally your own test suite via--run-tests). - Creates a brand-new branch off
--base-branch(never touchesmain/masterdirectly), commits, pushes, and opens a PR. - On any failure, discards the change and deletes the local branch — nothing partial is ever left committed.
- Prints a summary of created / skipped / failed findings.
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtOptional, only needed if you want JS/TS syntax checking: install Node.js (the agent auto-detects it and skips that check gracefully if it's missing).
| Variable | Required? | Description |
|---|---|---|
ANTHROPIC_API_KEY |
Always | From https://console.anthropic.com/settings/keys |
GITHUB_TOKEN |
Unless --dry-run |
A token with permission to push branches and open PRs on the target repo |
GITHUB_REPOSITORY |
Optional | owner/repo. If unset, the agent infers it from the repo's origin remote |
BASE_BRANCH |
Optional | Default main. Can also be passed as --base-branch |
ANTHROPIC_MODEL |
Optional | Default claude-sonnet-5. Check https://docs.claude.com for current model names |
TEST_COMMAND |
Optional | Default pytest -q. Used only with --run-tests |
Use a fine-grained personal access token scoped to only the repo(s) this agent should touch, with:
- Contents: Read and write
- Pull requests: Read and write
Do not grant it Administration or Actions permissions. If you'd rather use
a classic token, the repo scope is sufficient but is broader than necessary.
export ANTHROPIC_API_KEY="sk-ant-..."
export GITHUB_TOKEN="github_pat_..."
export GITHUB_REPOSITORY="your-org/your-repo" # optional if origin remote is already set{
"findings": [
{
"id": "SEC-001",
"file": "app/db.py",
"severity": "high",
"type": "sql_injection",
"description": "User input is concatenated directly into a SQL query string.",
"line_start": 10,
"line_end": 12,
"code_snippet": "query = \"SELECT * FROM users WHERE id = \" + user_id"
}
]
}Only id and file are required; everything else is optional context passed
to the model. See scan_results.example.json for a runnable example. file
must be a path relative to the repo root — absolute paths and ../ traversal
outside the repo are rejected.
This exercises the full pipeline — including a real Claude API call and syntax validation — without touching any GitHub repository.
# 1. Create a throwaway git repo
mkdir -p /tmp/sandbox-repo/app && cd /tmp/sandbox-repo
git init -b main
git config user.email "you@example.com"
git config user.name "Your Name"
cat > app/db.py << 'EOF'
def get_user(cursor, user_id):
query = "SELECT * FROM users WHERE id = " + user_id
cursor.execute(query)
return cursor.fetchone()
EOF
git add . && git commit -m "initial commit"
# 2. Point a scan file at the vulnerable file
cat > scan_results.json << 'EOF'
{
"findings": [
{
"id": "SEC-001",
"file": "app/db.py",
"severity": "high",
"type": "sql_injection",
"description": "String concatenation is used to build a SQL query from user input.",
"code_snippet": "query = \"SELECT * FROM users WHERE id = \" + user_id"
}
]
}
EOF
# 3. Dry run: calls Claude, validates syntax, prints the diff, commits nothing
export ANTHROPIC_API_KEY="sk-ant-..."
python /path/to/auto_pr_agent.py \
--repo-path /tmp/sandbox-repo \
--scan-file /tmp/sandbox-repo/scan_results.json \
--dry-run -vConfirm the printed diff looks correct. Then, to test the real GitHub flow,
push sandbox-repo to a throwaway repo you control and re-run without
--dry-run, with GITHUB_TOKEN and GITHUB_REPOSITORY set:
git remote add origin https://github.com/<you>/sandbox-repo.git
git push -u origin main
export GITHUB_TOKEN="github_pat_..."
python /path/to/auto_pr_agent.py \
--repo-path /tmp/sandbox-repo \
--scan-file /tmp/sandbox-repo/scan_results.jsonCheck the repo on GitHub: you should see a new fix/sec-001-... branch and an
open PR against main, not a direct commit to main.
The agent enforces "never commit to main" in code, but for a real production setup also add GitHub-side protection so a bug in any tool can't bypass it:
- Enable branch protection on
main/master: require PRs, require at least one human approval, disallow force-pushes. - Scope the bot's token to only the repos it needs.
- Run the agent with
--dry-runfirst in CI on a schedule, and only run it for real after a human has skimmed the dry-run output. - Never configure anything to auto-merge these PRs.
python auto_pr_agent.py \
--repo-path PATH # local git repo (default: ".")
--scan-file PATH # default: "scan_results.json"
--base-branch NAME # default: "main"
--dry-run # no commit/push/PR; prints diffs
--run-tests # run --test-command before committing each fix
--test-command "..." # default: "pytest -q"
--max-findings N # only process the first N findings
--anthropic-model NAME # default: "claude-sonnet-5"
--max-output-tokens N # default: 8000
-v / --verbose # debug logging