Skip to content
Closed
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
79 changes: 79 additions & 0 deletions pr_agent/config_loader.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import copy
import threading
from os.path import abspath, dirname, join
from pathlib import Path
from typing import Optional
Expand Down Expand Up @@ -91,6 +93,83 @@ def _find_pyproject() -> Optional[Path]:
get_settings().load_file(pyproject_path, env=f'tool.{PR_AGENT_TOML_KEY}')


# --- State-leak fix (issue #2345) -------------------------------------------
# apply_repo_settings() merges a repo's .pr_agent.toml into the shared settings
# singleton. When a later PR comes from a repo with no .pr_agent.toml, the loader
# early-exits and the previous repo's keys linger for the life of the process.
#
# Rather than snapshotting and restoring ALL settings (which would also wipe
# legitimate per-request config set before apply_repo_settings — e.g.
# config.extra_config_url, config.is_auto_command), we track the exact keys each
# repo/extra .pr_agent.toml overrode and revert only those on the next load.
_OVERRIDE_MISSING = object() # sentinel: key did not exist before the override
# {"SECTION.KEY" (upper, for dedup): (section, key, <pre-override value or _OVERRIDE_MISSING>)}
_APPLIED_REPO_OVERRIDES: dict = {}
# Serializes record/revert so overlapping background webhook tasks in one process
Comment on lines +105 to +108

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. Override ledger crosses requests 🐞 Bug ⛨ Security

_APPLIED_REPO_OVERRIDES is module-global, but reset_repo_settings_overrides() replays it onto
whichever object get_settings() returns on the next call, which can be a different per-request
settings clone. If a repo config overrides a request-scoped credential (e.g.
gitlab.personal_access_token), a subsequent request can have its injected token overwritten with a
prior request’s value during reset.
Agent Prompt
### Issue description
`_APPLIED_REPO_OVERRIDES` is shared across the whole process, but `get_settings()` may return different settings objects across requests (global singleton vs per-request clone). This allows reset to apply “prior values” captured from request A onto request B’s settings object.

### Issue Context
Several servers create a per-request clone via `context["settings"] = copy.deepcopy(global_settings)` and mutate it (e.g., injecting GitLab tokens) before calling `apply_repo_settings()`. Because the override ledger is not scoped to the effective settings object, resets can overwrite request-scoped values.

### Fix Focus Areas
- pr_agent/config_loader.py[96-169]
- pr_agent/git_providers/utils.py[245-335]
- pr_agent/servers/gitlab_webhook.py[206-263]

### Implementation direction
- Make the override ledger **per effective settings object**, not module-global.
  - Option A (recommended): use a `weakref.WeakKeyDictionary` mapping `settings_obj -> ledger_dict`, where `settings_obj = get_settings()`.
  - Option B: attach a private attribute to the Dynaconf instance (e.g., `settings._pr_agent_repo_overrides = {...}`) if Dynaconf objects allow it reliably.
  - Option C: store the ledger in `starlette_context.context` when `context["settings"]` exists, and fall back to a module-global ledger only when using `global_settings`.
- Ensure `note_repo_setting_override()` and `reset_repo_settings_overrides()` both read/write the same per-object ledger.
- Keep locking, but lock should protect the per-object ledger and its associated section rebuild operations.

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

# cannot interleave and corrupt the shared singleton. Full cross-request isolation
# still relies on a per-request context["settings"] clone.
_SETTINGS_RESET_LOCK = threading.RLock()


def note_repo_setting_override(section: str, key: str):
"""Record the pre-override value of ``settings[section][key]`` so the next
``reset_repo_settings_overrides()`` can revert exactly this key.

Must be called from the repo/extra config merge BEFORE the value is written.
Reads via ``get_settings()`` so it captures the effective object (including a
per-request ``context["settings"]`` clone). The first recorded value for a key
wins, so repeated overrides within one load still revert to the pre-load value.
"""
settings = get_settings()
dedup_key = f"{section}.{key}".upper()
with _SETTINGS_RESET_LOCK:
if dedup_key in _APPLIED_REPO_OVERRIDES:
return
prior = settings.get(f"{section}.{key}", _OVERRIDE_MISSING)
# Keep the sentinel's identity (don't deepcopy it) so revert can tell
# "did not exist" from a real stored value.
_APPLIED_REPO_OVERRIDES[dedup_key] = (
section, key, prior if prior is _OVERRIDE_MISSING else copy.deepcopy(prior)
)


def reset_repo_settings_overrides():
"""Revert the keys overridden by the previous repo/extra ``.pr_agent.toml`` load.

Invoked at the top of ``apply_repo_settings()`` so a previously-reviewed repo's
settings cannot leak into a subsequent PR. Only the specific keys recorded by
``note_repo_setting_override()`` are touched, so runtime/base configuration set
outside the repo-settings merge (extra_config_url, is_auto_command, ...) is left
intact. Operates on ``get_settings()`` so it covers per-request clones too.

Reverts are applied by rebuilding each affected section once: Dynaconf's unset()
cannot drop a nested key, and a whole-section replace is the same mechanism the
merge uses. Sibling keys not in the ledger (e.g. config.is_auto_command) are
preserved because the rebuild starts from the section's current contents.
"""
settings = get_settings()
with _SETTINGS_RESET_LOCK:
if not _APPLIED_REPO_OVERRIDES:
return
reverts_by_section: dict = {}
for section, key, prior in _APPLIED_REPO_OVERRIDES.values():
reverts_by_section.setdefault(section, []).append((key, prior))
for section, reverts in reverts_by_section.items():
section_dict = copy.deepcopy(settings.as_dict().get(section.upper(), {}))
for key, prior in reverts:
# Drop any existing spelling of the key (Dynaconf stores section keys
# in their original case), then restore the prior value if it existed.
for existing in [k for k in section_dict if k.upper() == key.upper()]:
section_dict.pop(existing)
if prior is not _OVERRIDE_MISSING:
section_dict[key] = copy.deepcopy(prior)
settings.unset(section, force=True)
if section_dict:
settings.set(section, section_dict, merge=False)
_APPLIED_REPO_OVERRIDES.clear()
# ---------------------------------------------------------------------------


def apply_secrets_manager_config():
"""
Retrieve configuration from AWS Secrets Manager and override existing settings
Expand Down
21 changes: 20 additions & 1 deletion pr_agent/git_providers/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
from dynaconf.loaders import env_loader
from starlette_context import context

from pr_agent.config_loader import get_settings
from pr_agent.config_loader import (get_settings,
note_repo_setting_override,
reset_repo_settings_overrides)
from pr_agent.custom_merge_loader import (MAX_TOML_SIZE_IN_BYTES,
validate_file_security)
from pr_agent.git_providers import get_git_provider_with_context
Expand Down Expand Up @@ -218,6 +220,9 @@ def _apply_settings_from_file(path: str, label: str):
continue
section_dict = copy.deepcopy(get_settings().as_dict().get(section, {}))
for key, value in contents.items():
# Record the pre-override value first so it can be reverted on the
# next apply_repo_settings() (prevents cross-repo state leaks).
note_repo_setting_override(section, key)
section_dict[key] = value
get_settings().unset(section)
get_settings().set(section, section_dict, merge=False)
Expand All @@ -240,6 +245,12 @@ def _apply_settings_from_file(path: str, label: str):
def apply_repo_settings(pr_url):
os.environ["AUTO_CAST_FOR_DYNACONF"] = "false"

# Revert keys overridden by a previously-reviewed repo's .pr_agent.toml (or the extra
# config merged below) so they cannot leak into this call via the shared settings
# singleton. Only repo-settings-managed keys are touched — see
# reset_repo_settings_overrides().
reset_repo_settings_overrides()

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. Stale claude model keys 🐞 Bug ≡ Correctness

With the new selective reset, only keys recorded via note_repo_setting_override() are reverted, but
apply_repo_settings() can also mutate config.model_weak and config.fallback_models via
set_claude_model() without recording them. This allows repo-specific model_weak/fallback_models to
leak into subsequent repos even after config.model is reverted, creating inconsistent model
selection.
Agent Prompt
### Issue description
After adding `reset_repo_settings_overrides()` at the start of `apply_repo_settings()`, the reset only reverts keys recorded during TOML merges. However, `set_claude_model()` mutates additional settings (`config.model_weak`, `config.fallback_models`) without recording them, so those values can persist across repos.

### Issue Context
- Ledger entries are only created in the TOML merge loops that call `note_repo_setting_override(section, key)`.
- `set_claude_model()` sets multiple keys directly via `get_settings().set(...)`.

### Fix Focus Areas
- pr_agent/git_providers/utils.py[245-333]
- pr_agent/git_providers/utils.py[452-459]
- pr_agent/config_loader.py[114-155]

### Suggested fix
Before mutating any additional keys in `set_claude_model()`, record their prior values via `note_repo_setting_override("config", "model_weak")` and `note_repo_setting_override("config", "fallback_models")` (and optionally `model` for completeness). This ensures the next `reset_repo_settings_overrides()` fully restores the model-related configuration to its pre-repo state.

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


# Apply external/shared config FIRST, before constructing the git provider:
# provider initialisers (e.g. GitLabProvider reads GITLAB.PERSONAL_ACCESS_TOKEN
# at __init__) need to see any provider-critical settings that come from the
Expand Down Expand Up @@ -365,6 +376,9 @@ def _apply_repo_settings_file(repo_settings_file):
continue
section_dict = copy.deepcopy(get_settings().as_dict().get(section.upper(), {}))
for key, value in contents.items():
# Record the pre-override value first so it can be reverted on the next
# apply_repo_settings() (prevents cross-repo state leaks, issue #2345).
note_repo_setting_override(section, key)
section_dict[key] = value
get_settings().unset(section)
get_settings().set(section, section_dict, merge=False)
Expand Down Expand Up @@ -440,6 +454,11 @@ def set_claude_model():
set the claude-sonnet-3.5 model easily (even by users), just by stating: --config.model='claude-3-5-sonnet'
"""
model_claude = "bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0"
# Record the pre-override values so these derived keys are reverted alongside
# config.model on the next apply_repo_settings() — otherwise a repo that selects
# the claude shorthand would leak model_weak/fallback_models into later repos.
for key in ("model", "model_weak", "fallback_models"):
note_repo_setting_override("config", key)
get_settings().set('config.model', model_claude)
get_settings().set('config.model_weak', model_claude)
get_settings().set('config.fallback_models', [model_claude])
153 changes: 152 additions & 1 deletion tests/unittest/test_apply_repo_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
import pytest
from starlette_context import context, request_cycle_context

from pr_agent.config_loader import get_settings, global_settings
from pr_agent.config_loader import (
_APPLIED_REPO_OVERRIDES,
get_settings,
global_settings,
note_repo_setting_override,
reset_repo_settings_overrides,
)
from pr_agent.git_providers import utils as git_utils

REPO_A_TOML = b"""
Expand Down Expand Up @@ -44,6 +50,17 @@ def fresh_global_settings():
global_settings.set(section, copy.deepcopy(contents), merge=False)


@pytest.fixture
def clean_repo_overrides():
"""Isolate the module-level repo-override ledger around each test so recorded
overrides don't carry across tests."""
saved = dict(_APPLIED_REPO_OVERRIDES)
_APPLIED_REPO_OVERRIDES.clear()
yield
_APPLIED_REPO_OVERRIDES.clear()
_APPLIED_REPO_OVERRIDES.update(saved)


def _extra_instructions(section: str) -> str:
return get_settings().get(f"{section}.extra_instructions", "") or ""

Expand Down Expand Up @@ -119,3 +136,137 @@ def test_unknown_section_does_not_leak_to_next_repo(self, fresh_global_settings,
git_utils.apply_repo_settings("https://git.example/projects/B/repos/b/pull-requests/1")
assert get_settings().get("my_custom_repo_section.foo") is None, \
"repo A's [my_custom_repo_section] leaked into repo B"


class TestRepoSettingsOverrideRevertWithoutClone:
"""Verify the override-revert safety net in `apply_repo_settings()` itself.

The tests above cover callers that install a per-request `context['settings']`
clone (every webhook server). These tests deliberately run WITHOUT that clone,
so `get_settings()` returns the shared `global_settings` — the situation for
long-running non-webhook callers such as `github_polling`, which processes many
PRs in one process. There, cross-repo leaks are prevented by reverting exactly
the keys the previous repo's `.pr_agent.toml` overrode.
"""

def test_leak_reverted_without_request_clone(
self, fresh_global_settings, clean_repo_overrides, monkeypatch
):
"""Repo A's extra_instructions must not survive into repo B when no
per-request clone isolates the two loads (issue #2345)."""
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 _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 _extra_instructions("pr_reviewer"), \
"repo A's [pr_reviewer].extra_instructions leaked into repo B"
assert "MARKER-FROM-REPO-A" not in _extra_instructions("pr_code_suggestions"), \
"repo A's [pr_code_suggestions].extra_instructions leaked into repo B"

def test_runtime_flags_not_reverted(
self, fresh_global_settings, clean_repo_overrides, monkeypatch
):
"""config.is_auto_command / config.is_new_pr are set outside the repo-settings
merge, so the revert on the second apply_repo_settings() (from
PRAgent._handle_request) must leave them intact — auto-command flows depend on it."""
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")
# Server sets request-scoped runtime flags after the first apply.
get_settings().set("config.is_auto_command", True)
get_settings().set("config.is_new_pr", False)

# Second apply for a repo without .pr_agent.toml (e.g. the per-command re-apply).
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 get_settings().config.is_auto_command is True, \
"is_auto_command was wiped by the second apply_repo_settings()"
assert get_settings().config.is_new_pr is False, \
"is_new_pr was wiped by the second apply_repo_settings()"
# ...while the actual repo-settings leak is still fixed.
assert "MARKER-FROM-REPO-A" not in _extra_instructions("pr_reviewer")

def test_non_repo_config_is_not_reverted(
self, fresh_global_settings, clean_repo_overrides, monkeypatch
):
"""Base/runtime config set outside the repo-settings merge (e.g. an operator's
config.extra_config_url) must survive a subsequent apply_repo_settings()."""
get_settings().set("config.extra_config_url", "") # ensure no real fetch
get_settings().set("config.output_relevant_configurations", True)

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")
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 get_settings().config.output_relevant_configurations is True, \
"a non-repo config value was clobbered by the repo-settings revert"

def test_revert_targets_effective_settings_object(
self, fresh_global_settings, clean_repo_overrides, monkeypatch
):
"""Record/revert must operate on the object get_settings() returns (e.g. a
per-request context["settings"] clone), never only global_settings."""
clone = copy.deepcopy(global_settings)
monkeypatch.setattr("pr_agent.config_loader.get_settings", lambda *a, **k: clone)

# Simulate a repo override recorded + written on the effective (clone) object.
note_repo_setting_override("pr_reviewer", "extra_instructions")
clone.set("pr_reviewer.extra_instructions", "LEAK-ON-CLONE")
assert clone.get("pr_reviewer.extra_instructions") == "LEAK-ON-CLONE"

reset_repo_settings_overrides()

assert clone.get("pr_reviewer.extra_instructions") != "LEAK-ON-CLONE", \
"revert did not operate on the effective (context clone) settings object"
assert global_settings.get("pr_reviewer.extra_instructions") != "LEAK-ON-CLONE", \
"global_settings must be untouched when the effective object is a clone"

def test_claude_shorthand_model_keys_do_not_leak(
self, fresh_global_settings, clean_repo_overrides, monkeypatch
):
"""A repo selecting the 'claude-3-5-sonnet' shorthand triggers set_claude_model(),
which also rewrites config.model_weak / config.fallback_models. Those derived keys
must be reverted (not just config.model) for a later repo that doesn't use it."""
baseline_model_weak = get_settings().get("config.model_weak", None)
baseline_fallbacks = copy.deepcopy(get_settings().get("config.fallback_models", None))

monkeypatch.setattr(
"pr_agent.git_providers.utils.get_git_provider_with_context",
lambda url: FakeGitProvider(b'[config]\nmodel = "claude-3-5-sonnet"\n'),
)
git_utils.apply_repo_settings("https://git.example/projects/A/repos/a/pull-requests/1")
assert "claude" in (get_settings().get("config.model_weak", "") or "").lower(), \
"precondition: set_claude_model() should have rewritten model_weak"

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 get_settings().get("config.model_weak", None) == baseline_model_weak, \
"config.model_weak leaked from the claude shorthand into the next repo"
assert get_settings().get("config.fallback_models", None) == baseline_fallbacks, \
"config.fallback_models leaked from the claude shorthand into the next repo"
Loading