Skip to content
Merged
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
112 changes: 112 additions & 0 deletions config/repo_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,17 @@
reviewer = get_default_reviewer()
"""

import logging
import os
import time
from pathlib import Path
from typing import Any, cast

import yaml

_LOGGER_NAME = "egg.repo_config"
logger = logging.getLogger(_LOGGER_NAME)


def _get_config_path() -> Path:
"""Get the path to repositories.yaml config file.
Expand Down Expand Up @@ -356,6 +360,114 @@ def reload_config() -> None:
"""
global _checkpoint_repos_cache
_checkpoint_repos_cache = None
# Per-repo role patterns are cached inside egg_restrictions; clearing
# that cache here keeps the SIGHUP path single-entry. Imported lazily
# so this module doesn't pull egg_restrictions at every config load.
try:
from egg_restrictions.patterns import reset_pattern_cache

reset_pattern_cache()
except ImportError:
pass


_VALID_ROLE_PATTERN_KEYS = frozenset({"tests_globs", "code_globs", "docs_globs"})


def get_repo_role_patterns(repo: str) -> dict[str, list[str]] | None:
"""Get the per-repo role-pattern overrides for a repository (#2528).

Repos can declare alternate test/code/docs file conventions in
``repositories.yaml`` so non-Python repos (Go, JS/TS, …) get correct
coder/tester/documenter boundaries. Only the language-convention
glob lists are configurable; security-relevant blocklists
(``.egg-state/contracts/``, ``.github/``) stay fixed and cannot be
relaxed by the target repo.

Schema (all keys optional):

.. code-block:: yaml

repo_settings:
owner/example-go-repo:
role_patterns:
tests_globs: ["**/*_test.go", "**/testdata/**"]
code_globs: ["**/*.go"]
docs_globs: ["**/*.md", "docs/"]

Args:
repo: Repository in ``owner/repo`` format.

Returns:
A dict containing only the keys the repo configured (any subset
of ``tests_globs`` / ``code_globs`` / ``docs_globs`` whose value
is a non-empty list of strings). Returns ``None`` when no
``role_patterns`` block is set, or when every configured key is
invalid.

Dropped input emits a WARNING log so operators can correlate a
repo's stale globs with the offending key/value:

- Unknown keys (e.g. an attempt to invent a
``contracts_blocklist`` knob, or a typo such as
``tests_glob`` missing the trailing ``s``).
- Non-list values (``tests_globs: 42``).
- Non-string list entries (``tests_globs: [null]``).

Defense-in-depth: dropping unknown keys at the parser layer
keeps a misconfigured repo from widening security boundaries.
The pattern builders also ignore anything outside the three
knobs, but the parser is the single place that produces a
diagnostic signal.
"""
raw = get_repo_setting(repo, "role_patterns", None)
if raw is None:
return None
if not isinstance(raw, dict):
logger.warning(
"repositories.yaml: role_patterns for %r must be a mapping, got %s; "
"ignoring entire block",
repo,
type(raw).__name__,
)
return None

out: dict[str, list[str]] = {}
for key, value in raw.items():
if key not in _VALID_ROLE_PATTERN_KEYS:
logger.warning(
"repositories.yaml: role_patterns key %r is not recognised for "
"repo %r (valid keys: %s); dropping",
key,
repo,
sorted(_VALID_ROLE_PATTERN_KEYS),
)
continue
if not isinstance(value, list):
logger.warning(
"repositories.yaml: role_patterns.%s for repo %r must be a list, got %s; dropping",
key,
repo,
type(value).__name__,
)
continue
cleaned: list[str] = []
for item in value:
if isinstance(item, str) and item:
cleaned.append(item)
else:
logger.warning(
"repositories.yaml: role_patterns.%s for repo %r contains "
"invalid entry %r (expected non-empty string); dropping that "
"entry",
key,
repo,
item,
)
if cleaned:
out[key] = cleaned

return out or None


def get_all_checkpoint_repos() -> frozenset[str]:
Expand Down
27 changes: 26 additions & 1 deletion gateway/agent_restrictions.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,18 @@
AGENT_PATTERNS,
AUTOFIXER_PATTERNS,
CONFLICT_RESOLVER_PATTERNS,
DEFAULT_CODE_GLOBS,
DEFAULT_DOCS_GLOBS,
DEFAULT_TESTS_GLOBS,
INSPECTOR_PATTERNS,
OVERSEER_PATTERNS,
AgentFilePattern,
AgentRole,
build_agent_patterns,
get_agent_pattern_for_repo,
get_agent_patterns_for_repo,
load_repo_pattern_override,
reset_pattern_cache,
)

logger = logging.getLogger("gateway.agent_restrictions")
Expand All @@ -50,13 +58,22 @@
def partition_files_by_role(
role: str,
files: list[str],
repo: str | None = None,
) -> tuple[list[str], list[str]]:
"""Split ``files`` into ``(allowed, blocked)`` by what ``role`` may write.

Used by the gateway's push handler to decide which files in a push
diff would be rejected by the role's AgentFilePattern and therefore
need to be auto-filtered out by the per-commit rewriter (#1882).

Args:
role: The agent role identifier.
files: Files in the push diff.
repo: Optional ``owner/repo`` for per-repo pattern overrides
(#2528). When set, the role's pattern reflects the
``role_patterns:`` block in ``repositories.yaml`` for this
repo; when ``None``, falls back to global defaults.

Behaviour:

- Unknown role → ``([], list(files))`` — every file is blocked, and
Expand All @@ -71,7 +88,7 @@ def partition_files_by_role(
if not files:
return [], []

pattern = get_agent_pattern(role)
pattern = get_agent_pattern(role, repo=repo)
if pattern is None:
logger.warning(
"partition_files_by_role_unknown_role",
Expand All @@ -96,12 +113,20 @@ def partition_files_by_role(
"AgentRestrictionResult",
"AgentRole",
"CONFLICT_RESOLVER_PATTERNS",
"DEFAULT_CODE_GLOBS",
"DEFAULT_DOCS_GLOBS",
"DEFAULT_TESTS_GLOBS",
"INSPECTOR_PATTERNS",
"OVERSEER_PATTERNS",
"build_agent_patterns",
"check_agent_file_access",
"check_agent_gh_operation",
"get_agent_pattern",
"get_agent_pattern_for_repo",
"get_agent_patterns_for_repo",
"load_repo_pattern_override",
"partition_files_by_role",
"reset_pattern_cache",
"validate_agent_push",
]

Expand Down
2 changes: 1 addition & 1 deletion gateway/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -1679,7 +1679,7 @@ def git_push() -> tuple[Response, int] | Response:
if role_for_sha and role_for_sha != session_role:
pulled_commits_summary.append({"sha": sha, "author_role": role_for_sha})

allowed_own, blocked_own = _partition_fn(session_role, own_files)
allowed_own, blocked_own = _partition_fn(session_role, own_files, repo=repo)

if unregistered_files and enforce:
audit_log(
Expand Down
Loading
Loading