Git provider
Github Cloud
System Info
pr-agent v0.32 (commit 1b0609a) ‚bitbucket_server_webhook target
Docker image built unmodified from upstream docker/Dockerfile (--target bitbucket_server_webhook), no local patches
Kubernetes, single replica
Model: anthropic/claude-opus-4-20250514 (fallback: anthropic/claude-sonnet-4-20250514)
Python 3.12, Bitbucket Data Center self-hosted
Bug details
TL;DR
When two PRs in two different repositories are reviewed by the same pr-agent process, extra_instructions (and any other section) from a .pr_agent.toml loaded for a PR in repo A persist into the review of a PR in repo B that does not have a .pr_agent.toml. Cause: apply_repo_settings() mutates the module-level global_settings singleton without resetting it first; when the next repo has no .pr_agent.toml, get_repo_settings() returns "", the loader early-exits, and the previously-applied sections leak.
This is not a race condition ‚it is deterministic, order-dependent behavior across webhook requests served by the same Python process.
Environment
- pr-agent
v0.32 (1b0609a); same code paths on main at time of filing.
- Observed via the
bitbucket_server_webhook target; the same apply_repo_settings() is called from the GitHub and GitLab webhook paths, so they are affected identically.
- Any deployment with ‚Č• 1 repository that has a
.pr_agent.toml.
Steps to reproduce
Self-contained pytest reproducer (no webhook, no network). Drop into tests/unittest/ and run:
tests/unittest/test_apply_repo_settings.py
import copy
import pytest
from pr_agent.config_loader import get_settings, global_settings
from pr_agent.git_providers import utils as git_utils
REPO_A_TOML = b"""
[pr_reviewer]
extra_instructions = "MARKER-FROM-REPO-A"
[pr_code_suggestions]
extra_instructions = "MARKER-FROM-REPO-A"
"""
class FakeGitProvider:
def __init__(self, repo_settings: bytes):
self._repo_settings = repo_settings
def get_repo_settings(self):
return self._repo_settings
def is_supported(self, feature):
return False
def publish_comment(self, body):
pass
def publish_persistent_comment(self, *args, **kwargs):
pass
@pytest.fixture
def fresh_global_settings():
snapshot = copy.deepcopy(global_settings.as_dict())
yield
for section in set(global_settings.as_dict().keys()) - set(snapshot.keys()):
global_settings.unset(section)
for section, contents in snapshot.items():
global_settings.unset(section)
global_settings.set(section, copy.deepcopy(contents), merge=False)
class TestApplyRepoSettings:
def _extra_instructions(self, section: str) -> str:
return get_settings().get(f"{section}.extra_instructions", "") or ""
def test_repo_settings_from_toml_are_applied(self, fresh_global_settings, monkeypatch):
monkeypatch.setattr(
"pr_agent.git_providers.utils.get_git_provider_with_context",
lambda url: FakeGitProvider(REPO_A_TOML),
)
git_utils.apply_repo_settings("https://git.example/projects/A/repos/a/pull-requests/1")
assert "MARKER-FROM-REPO-A" in self._extra_instructions("pr_reviewer")
assert "MARKER-FROM-REPO-A" in self._extra_instructions("pr_code_suggestions")
def test_repo_without_toml_does_not_inherit_previous_repo_settings(
self, fresh_global_settings, monkeypatch
):
monkeypatch.setattr(
"pr_agent.git_providers.utils.get_git_provider_with_context",
lambda url: FakeGitProvider(REPO_A_TOML),
)
git_utils.apply_repo_settings("https://git.example/projects/A/repos/a/pull-requests/1")
assert "MARKER-FROM-REPO-A" in self._extra_instructions("pr_reviewer"), (
"precondition"
)
monkeypatch.setattr(
"pr_agent.git_providers.utils.get_git_provider_with_context",
lambda url: FakeGitProvider(b""),
)
git_utils.apply_repo_settings("https://git.example/projects/B/repos/b/pull-requests/1")
assert "MARKER-FROM-REPO-A" not in self._extra_instructions("pr_reviewer"), (
"repo A's [pr_reviewer].extra_instructions leaked into repo B"
)
assert "MARKER-FROM-REPO-A" not in self._extra_instructions("pr_code_suggestions"), (
"repo A's [pr_code_suggestions].extra_instructions leaked into repo B"
)
Equivalent manual scenario:
- Repo A contains
.pr_agent.toml:
[pr_reviewer]
extra_instructions = "Always end your review with: MARKER-FROM-REPO-A"
- Repo B does not contain
.pr_agent.toml.
- Open a PR in repo A ‚Üí pr-agent review contains
MARKER-FROM-REPO-A (expected).
- Without restarting the pod, open a PR in repo B ‚Üí pr-agent review also contains
MARKER-FROM-REPO-A (unexpected).
Output on clean v0.32 (1b0609a)
tests/unittest/test_apply_repo_settings.py::TestApplyRepoSettings::test_repo_settings_from_toml_are_applied PASSED
tests/unittest/test_apply_repo_settings.py::TestApplyRepoSettings::test_repo_without_toml_does_not_inherit_previous_repo_settings FAILED
AssertionError: repo A's [pr_reviewer].extra_instructions leaked into repo B
assert 'MARKER-FROM-REPO-A' not in 'MARKER-FROM-REPO-A'
Hit in production: a PR in a repo with no .pr_agent.toml received code suggestions referencing a utility class that exists only in a different repo, matching verbatim that other repo's .pr_agent.toml extra_instructions. Initially looked like the model "learning from the codebase" ‚ÄĒ in fact, a configuration leak.
Expected behavior
apply_repo_settings() for a PR in repo B should leave global_settings with only the defaults loaded at process startup + whatever repo B's own .pr_agent.toml provides. No content from repo A's .pr_agent.toml should be reachable.
Actual behavior
Sections from repo A's .pr_agent.toml remain in global_settings indefinitely until:
- a subsequent repo overwrites the same section's specific keys (partial overlap only ‚ÄĒ keys that differ still leak), or
- the process is restarted.
Root cause
In pr_agent/git_providers/utils.py:
def apply_repo_settings(pr_url):
...
repo_settings = git_provider.get_repo_settings() # returns b"" on 404
...
if repo_settings: # early-exit for repos w/o .pr_agent.toml
...
for section, contents in new_settings.as_dict().items():
...
section_dict = copy.deepcopy(get_settings().as_dict().get(section, {})) # inherits previous state
for key, value in contents.items():
section_dict[key] = value
get_settings().unset(section)
get_settings().set(section, section_dict, merge=False)
- No reset before apply.
apply_repo_settings() is additive on top of whatever get_settings() currently holds. There is no "restore to process-startup defaults" step.
- Empty
.pr_agent.toml path skips everything. When repo B has no config file, the entire block under if repo_settings: is skipped, so sections set by a previous repo's load are never touched.
global_settings is module-level. config_loader.py:17 defines global_settings = Dynaconf(...) at module scope. get_settings() returns this singleton (the context["settings"] path is not populated by the webhook flows ‚ÄĒ RawContextMiddleware does not install a per-request settings clone). Every request in the same process mutates and reads the same object.
- Per-section merge inherits stale keys. Even when repo B does have a
.pr_agent.toml, section_dict = copy.deepcopy(get_settings().as_dict().get(section, {})) carries over keys that were set by repo A but are not specified in repo B.
Impact
- Reviewers in low-config repos see suggestions referring to identifiers, utility classes, style rules, or markers that belong to a completely different repository's codebase.
- Superficially this looks like the model "learning from the codebase" or hallucinating with suspicious accuracy. In enterprise deployments this can generate false alarms about data handling / model training.
- Deterministic and persistent until pod restart. With a single-replica deployment (default), a single
.pr_agent.toml in any repo served by the webhook effectively becomes a silent global default for all subsequent PRs in all repos.
Proposed fix
Snapshot global_settings on first use, restore at the top of apply_repo_settings() before loading per-repo config. Lazy snapshot so that any startup initialization that runs after module import (e.g. apply_secrets_manager_config()) is captured.
Diff: +36, ‚ąí1, two files. Applies cleanly to v0.32 (1b0609a). With the patch:
- the two regression tests above pass,
- the full existing unit test suite remains green (250 passed, 0 failed).
fix-state-leak.patch
diff --git a/pr_agent/config_loader.py b/pr_agent/config_loader.py
index ac7343f..ac0f85c 100644
--- a/pr_agent/config_loader.py
+++ b/pr_agent/config_loader.py
@@ -1,3 +1,4 @@
+import copy
from os.path import abspath, dirname, join
from pathlib import Path
from typing import Optional
@@ -91,6 +92,37 @@ if pyproject_path is not None:
get_settings().load_file(pyproject_path, env=f'tool.{PR_AGENT_TOML_KEY}')
+# --- State-leak fix ---------------------------------------------------------
+# Snapshot of global_settings captured on first call to reset_to_startup_defaults().
+# Lazy (not taken at import time) so that startup tasks like
+# apply_secrets_manager_config() that run after module import are included.
+_STARTUP_SETTINGS_SNAPSHOT: Optional[dict] = None
+
+
+def reset_to_startup_defaults():
+ """Restore ``global_settings`` to the state captured at the first call.
+
+ Must be invoked at the top of ``apply_repo_settings()`` so that sections
+ set by a previously-reviewed repo's ``.pr_agent.toml`` do not leak into
+ subsequent PRs processed by the same Python process.
+ """
+ global _STARTUP_SETTINGS_SNAPSHOT
+ if _STARTUP_SETTINGS_SNAPSHOT is None:
+ _STARTUP_SETTINGS_SNAPSHOT = copy.deepcopy(global_settings.as_dict())
+ return # nothing to restore on the very first call
+
+ current_sections = set(global_settings.as_dict().keys())
+ snapshot_sections = set(_STARTUP_SETTINGS_SNAPSHOT.keys())
+ # Remove sections that appeared after startup (i.e. injected by apply_repo_settings).
+ for section in current_sections - snapshot_sections:
+ global_settings.unset(section)
+ # Restore pre-existing sections to their startup values.
+ for section, contents in _STARTUP_SETTINGS_SNAPSHOT.items():
+ global_settings.unset(section)
+ global_settings.set(section, copy.deepcopy(contents), merge=False)
+# ---------------------------------------------------------------------------
+
+
def apply_secrets_manager_config():
"""
Retrieve configuration from AWS Secrets Manager and override existing settings
diff --git a/pr_agent/git_providers/utils.py b/pr_agent/git_providers/utils.py
index 1e64b95..539b6f8 100644
--- a/pr_agent/git_providers/utils.py
+++ b/pr_agent/git_providers/utils.py
@@ -6,13 +6,16 @@ import traceback
from dynaconf import Dynaconf
from starlette_context import context
-from pr_agent.config_loader import get_settings
+from pr_agent.config_loader import get_settings, reset_to_startup_defaults
from pr_agent.git_providers import get_git_provider_with_context
from pr_agent.log import get_logger
def apply_repo_settings(pr_url):
os.environ["AUTO_CAST_FOR_DYNACONF"] = "false"
+ # Reset first so that .pr_agent.toml from a previously-reviewed repo
+ # cannot leak into this call via the module-level global_settings singleton.
+ reset_to_startup_defaults()
git_provider = get_git_provider_with_context(pr_url)
if get_settings().config.use_repo_settings_file:
repo_settings_file = None
Alternative, bigger change: switch get_settings() to always return a per-request clone of global_settings, populated via context["settings"] and set by a FastAPI middleware. This would eliminate the ambient-singleton design that makes this class of bug hard to reason about, but touches many more call sites.
Workarounds in the meantime
- Lift
extra_instructions out of .pr_agent.toml into the container env (PR_REVIEWER__EXTRA_INSTRUCTIONS, PR_CODE_SUGGESTIONS__EXTRA_INSTRUCTIONS) so every request starts from the same baseline. Side effect: the instructions become global (sometimes undesirable ‚ÄĒ e.g. style rules specific to a frontend repo shouldn't apply to a backend repo).
- Periodic
kubectl rollout restart to cap leak window. Noisy, doesn't fix the class of bug.
PR
Happy to open a PR with the snapshot-and-restore patch above plus the regression tests. If you'd prefer the larger per-request-isolation route instead, say the word and I'll start that one.
Git provider
Github Cloud
System Info
pr-agent v0.32 (commit 1b0609a) ‚bitbucket_server_webhook target
Docker image built unmodified from upstream docker/Dockerfile (--target bitbucket_server_webhook), no local patches
Kubernetes, single replica
Model: anthropic/claude-opus-4-20250514 (fallback: anthropic/claude-sonnet-4-20250514)
Python 3.12, Bitbucket Data Center self-hosted
Bug details
TL;DR
When two PRs in two different repositories are reviewed by the same pr-agent process,
extra_instructions(and any other section) from a.pr_agent.tomlloaded for a PR in repo A persist into the review of a PR in repo B that does not have a.pr_agent.toml. Cause:apply_repo_settings()mutates the module-levelglobal_settingssingleton without resetting it first; when the next repo has no.pr_agent.toml,get_repo_settings()returns"", the loader early-exits, and the previously-applied sections leak.This is not a race condition ‚it is deterministic, order-dependent behavior across webhook requests served by the same Python process.
Environment
v0.32(1b0609a); same code paths onmainat time of filing.bitbucket_server_webhooktarget; the sameapply_repo_settings()is called from the GitHub and GitLab webhook paths, so they are affected identically..pr_agent.toml.Steps to reproduce
Self-contained pytest reproducer (no webhook, no network). Drop into
tests/unittest/and run:tests/unittest/test_apply_repo_settings.pyEquivalent manual scenario:
.pr_agent.toml:.pr_agent.toml.MARKER-FROM-REPO-A(expected).MARKER-FROM-REPO-A(unexpected).Output on clean
v0.32(1b0609a)Hit in production: a PR in a repo with no
.pr_agent.tomlreceived code suggestions referencing a utility class that exists only in a different repo, matching verbatim that other repo's.pr_agent.tomlextra_instructions. Initially looked like the model "learning from the codebase" ‚ÄĒ in fact, a configuration leak.Expected behavior
apply_repo_settings()for a PR in repo B should leaveglobal_settingswith only the defaults loaded at process startup + whatever repo B's own.pr_agent.tomlprovides. No content from repo A's.pr_agent.tomlshould be reachable.Actual behavior
Sections from repo A's
.pr_agent.tomlremain inglobal_settingsindefinitely until:Root cause
In
pr_agent/git_providers/utils.py:apply_repo_settings()is additive on top of whateverget_settings()currently holds. There is no "restore to process-startup defaults" step..pr_agent.tomlpath skips everything. When repo B has no config file, the entire block underif repo_settings:is skipped, so sections set by a previous repo's load are never touched.global_settingsis module-level.config_loader.py:17definesglobal_settings = Dynaconf(...)at module scope.get_settings()returns this singleton (thecontext["settings"]path is not populated by the webhook flows ‚ÄĒRawContextMiddlewaredoes not install a per-request settings clone). Every request in the same process mutates and reads the same object..pr_agent.toml,section_dict = copy.deepcopy(get_settings().as_dict().get(section, {}))carries over keys that were set by repo A but are not specified in repo B.Impact
.pr_agent.tomlin any repo served by the webhook effectively becomes a silent global default for all subsequent PRs in all repos.Proposed fix
Snapshot
global_settingson first use, restore at the top ofapply_repo_settings()before loading per-repo config. Lazy snapshot so that any startup initialization that runs after module import (e.g.apply_secrets_manager_config()) is captured.Diff: +36, ‚ąí1, two files. Applies cleanly to
v0.32(1b0609a). With the patch:fix-state-leak.patchAlternative, bigger change: switch
get_settings()to always return a per-request clone ofglobal_settings, populated viacontext["settings"]and set by a FastAPI middleware. This would eliminate the ambient-singleton design that makes this class of bug hard to reason about, but touches many more call sites.Workarounds in the meantime
extra_instructionsout of.pr_agent.tomlinto the container env (PR_REVIEWER__EXTRA_INSTRUCTIONS,PR_CODE_SUGGESTIONS__EXTRA_INSTRUCTIONS) so every request starts from the same baseline. Side effect: the instructions become global (sometimes undesirable ‚ÄĒ e.g. style rules specific to a frontend repo shouldn't apply to a backend repo).kubectl rollout restartto cap leak window. Noisy, doesn't fix the class of bug.PR
Happy to open a PR with the snapshot-and-restore patch above plus the regression tests. If you'd prefer the larger per-request-isolation route instead, say the word and I'll start that one.