Skip to content

apply_repo_settings() leaks .pr_agent.toml state across PRs from different repositories #2345

@tharatynowicz

Description

@tharatynowicz

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:

  1. Repo A contains .pr_agent.toml:
    [pr_reviewer]
    extra_instructions = "Always end your review with: MARKER-FROM-REPO-A"
  2. Repo B does not contain .pr_agent.toml.
  3. Open a PR in repo A ‚Üí pr-agent review contains MARKER-FROM-REPO-A (expected).
  4. 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)
  1. 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.
  2. 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.
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions