Files
Gitea-Tools/gitea_config.py
T
sysadminandClaude Opus 4.8 61c3a57df5 fix(mcp): consume canonical target root in cross-repository operations (Closes #741)
#706 introduced the immutable `canonical_repository_root` and #739/#740 routed
three consumption paths through it. The paths #740 left behind all shared one
shape: the *filesystem* guards had been migrated to the canonical root, but
*repository identity* still bottomed out in `_local_git_remote_url`, which ran
`git remote get-url` with `cwd=PROJECT_ROOT` — always the Gitea-Tools install
checkout. The two halves of a single assessment therefore described two
different repositories.

For a namespace bound to another repository this inverts the guards rather than
merely weakening them: an operation naming the genuinely bound target is
rejected, while one naming Gitea-Tools is accepted.

Central helper
--------------
Adds `_canonical_local_git_root()` as the single place a target-repository root
is resolved: the configured canonical root when one is declared, else
`PROJECT_ROOT`. A configured-but-invalid root is never silently replaced by the
install checkout. Single-repository behaviour is byte-for-byte unchanged.

Defects fixed
-------------
* `_local_git_remote_url` now runs in the canonical target root, which corrects
  every downstream identity consumer at once (`_resolve`, the #530 remote/repo
  guard, the anti-stomp org/repo fill, `_workspace_repository_slug`).
* `_verify_role_mutation_workspace` omitted `configured_canonical_root`, so the
  #274 branches-only / worktree-membership guards validated
  `Gitea-Tools/branches/` instead of the bound target. It now threads the root
  exactly as `_resolve_namespace_mutation_context` does.
* `_resolve` derived omitted coordinates by looking a remote up *by name*. A
  target checkout commonly names its remote `origin` rather than `prgs`, so the
  lookup returned None and the coordinates fell through to the remote-wide
  default target — an unrelated repository. It now prefers
  `_canonical_repository_slug`, which probes candidate remote names.
* Explicit `org`/`repo` short-circuit the #530 match check, so caller
  coordinates bypassed validation entirely. They may now only *confirm* a
  canonical binding, never override it, and fail closed in both directions.
* Reconciler ancestry, fetch, worktree-inventory and cleanup call sites, the
  author worktree derivation in `gitea_lock_issue`/`gitea_create_pr`, and the
  `control_clean` porcelain probe now use the canonical target root.
* `gitea_config`: v2-`environments` silently *dropped* `canonical_repository_root`
  during flattening, so such a namespace fell back to the install root — a
  fail-open. It is now validated and propagated. The v1 path validates it too,
  so all three loaders behave identically.

Preserved as installation-scoped
--------------------------------
Server parity (`master_parity_gate`), workflow/schema/skill loading, self-code
hashing and stale-runtime detection, and `mirror_refs.sh` lookup remain anchored
to `PROJECT_ROOT`. The `server_implementation` vs `target_repository` parity
dimensions from #740 stay separately labelled, and only the server dimension
gates mutations.

Tests
-----
`tests/test_issue_741_canonical_root_consumers.py` (51 tests, 52 subtests) drives
a role-by-operation matrix over author/reviewer/merger/reconciler against:
correct cross-repository target, wrong repository, wrong worktree, explicit
matching and mismatched coordinates, missing/invalid canonical root, immutable
first-bind, and request-override attempts — plus both cross-repository
directions and all three config loaders. Real git repositories, no network, no
branch deletion, no merge.

The module reinstalls the genuine `_local_git_remote_url` per test: an autouse
conftest fixture permanently reassigns it to a working-directory-independent
stub, which is precisely the behaviour under test.

Full suite: 3408 passed, 6 skipped, 2 failed. Both failures
(`test_issue_702_review_findings_f1_f6`, `test_reconciler_supersession_close`)
reproduce identically on clean master c908ed6050 and are pre-existing.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01AYGdWAwuA6UNDc9c3CGipU
2026-07-18 12:02:31 -04:00

1235 lines
50 KiB
Python

"""Canonical JSON runtime-profile configuration for Gitea MCP (issue #19).
One canonical config file defines every Gitea runtime profile. Each LLM MCP
launcher (Claude / Gemini / Codex) stays a *thin* launcher that only points at
that file and names a profile — no duplicated ``GITEA_USER_*`` / ``GITEA_PASS_*``
blocks, no raw tokens in client configs:
GITEA_MCP_CONFIG=/path/to/profiles.json # the canonical file
GITEA_MCP_PROFILE=prgs # which named profile to use
File shape (see ``gitea-mcp.example.json``)::
{
"version": 1,
"profiles": {
"prgs": {
"base_url": "https://gitea.prgs.cc",
"username": "jcwalker3",
"auth": {"type": "keychain", "id": "prgs-gitea-token"},
"default_owner": "Scaled-Tech-Consulting",
"execution_profile": "personal-prgs"
}
}
}
Auth is referenced *indirectly*, never inline:
- ``{"type": "keychain", "id": "prgs-gitea-token"}`` — resolved from the macOS
keychain at token-resolution time.
- ``{"type": "env", "name": "GITEA_TOKEN_PRGS"}`` — read from that env var.
Design constraints:
- **Backwards compatible.** With ``GITEA_MCP_CONFIG`` unset, every entry point
returns ``None`` and callers fall back to pure environment behaviour.
- **No inline secrets.** A raw ``token``/``password`` key is rejected. Token
values are resolved only via an auth *reference* and are never stored in, or
returned as, profile metadata.
- **No network.** Parsing only reads and decodes a local file. Token resolution
(keychain/env) happens separately, on demand.
- **Fail safely.** A missing file, invalid JSON, unsupported version, unknown/
unset selected profile, or an unresolvable secret reference raises
:class:`ConfigError` with a message that never includes file contents,
tokens, or passwords.
"""
import os
import re
import sys
import json
import tempfile
import subprocess
ENV_CONFIG_PATH = "GITEA_MCP_CONFIG"
ENV_PROFILE = "GITEA_MCP_PROFILE"
SUPPORTED_VERSION = 1
SUPPORTED_VERSIONS = (1, 2)
_AUTH_TYPES = ("keychain", "env")
# Profile names go into env vars, keychain ids, and JSON keys — keep them tame.
_PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
# v2 address segments (environment / service / identity) must be dot-free so
# the dotted profile address {env}.{service}.{identity} stays unambiguous.
_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
# Placeholder usernames must never activate (fail closed until provisioned).
_TBD_RE = re.compile(r"(?i)^tbd(-|$)")
# Keys that would mean an inline secret wherever they appear.
_INLINE_SECRET_KEYS = ("token", "password", "secret")
# ── Operation-name normalization table (#106; minimal subset landed in #103) ───
# Canonical operations are namespaced ({service}.{area}.{verb}). Legacy
# unqualified spellings are accepted ONLY through this explicit table — never
# by guessing. The same table is the documentation of record (see
# docs/gitea-execution-profiles.md) and is exercised by
# tests/test_op_normalization.py.
GITEA_OPERATION_ALIASES = {
"read": "gitea.read",
"review": "gitea.pr.review",
"comment": "gitea.pr.comment",
"issue.comment": "gitea.issue.comment",
"issue_comment": "gitea.issue.comment",
"approve": "gitea.pr.approve",
"request_changes": "gitea.pr.request_changes",
"merge": "gitea.pr.merge",
"pr.create": "gitea.pr.create",
"branch.push": "gitea.branch.push",
# Contexts-shape author verbs (#120) — the invariant checks below depend on
# "push"/"open_pr" normalizing to the two author-only ops.
"branch": "gitea.branch.create",
"commit": "gitea.repo.commit",
"push": "gitea.branch.push",
"open_pr": "gitea.pr.create",
}
_REVIEW_MERGE_OPS = frozenset({"gitea.pr.approve", "gitea.pr.merge"})
_AUTHOR_ONLY_OPS = frozenset({"gitea.pr.create", "gitea.branch.push"})
def normalize_operation(op, service="gitea"):
"""Return the canonical namespaced name for *op*, or fail closed (#106).
- already namespaced for this service (``{service}.*``) → unchanged
- known unqualified Gitea ops → mapped via ``GITEA_OPERATION_ALIASES``
- unqualified single-word ops on non-Gitea services → ``{service}.{op}``
- anything else — foreign service prefixes, dotted names outside the
table, unknown unqualified names — is unknown or ambiguous → ConfigError
Normalization never crosses services (a Gitea alias is never applied to
another service) and never widens permissions: an operation that cannot
be normalized grants and matches nothing.
"""
if not isinstance(op, str) or not op:
raise ConfigError("operation must be a non-empty string (fail closed)")
if op.startswith(service + "."):
return op
if service == "gitea" and op in GITEA_OPERATION_ALIASES:
return GITEA_OPERATION_ALIASES[op]
if service != "gitea" and "." not in op:
return f"{service}.{op}"
raise ConfigError(
f"operation {op!r} cannot be normalized safely for service "
f"'{service}' (unknown, ambiguous, or cross-service; fail closed)"
)
def check_operation(op, allowed, forbidden=(), service="gitea"):
"""Decide whether *op* is permitted. Returns ``(bool, reason)`` (#106).
Everything is normalized via :func:`normalize_operation` BEFORE any
membership check, so legacy and canonical spellings always compare equal.
Reasons: ``allowed``, ``invalid-operation``, ``invalid-forbidden-entry``,
``forbidden``, ``no-allowed-operations``, ``not-allowed``.
Fail-closed rules:
- an *op* that cannot be normalized is denied (``invalid-operation``)
- a forbidden entry that cannot be normalized denies the request
(``invalid-forbidden-entry``) — dropping it would silently narrow the
forbidden set, i.e. widen permissions
- an allowed entry that cannot be normalized is ignored — it grants
nothing, so permissions never widen
- ``forbidden`` always overrides ``allowed``
- an empty or missing allowed list denies everything
"""
try:
op_n = normalize_operation(op, service)
except ConfigError:
return (False, "invalid-operation")
forbidden_n = set()
for entry in (forbidden or ()):
try:
forbidden_n.add(normalize_operation(entry, service))
except ConfigError:
return (False, "invalid-forbidden-entry")
if op_n in forbidden_n:
return (False, "forbidden")
if not allowed:
return (False, "no-allowed-operations")
allowed_n = set()
for entry in allowed:
try:
allowed_n.add(normalize_operation(entry, service))
except ConfigError:
continue
if op_n in allowed_n:
return (True, "allowed")
return (False, "not-allowed")
def _normalize_op(service, op, addr):
"""Normalize *op* for identity *addr*, or fail closed with context."""
try:
return normalize_operation(op, service)
except ConfigError as exc:
raise ConfigError(f"identity '{addr}': {exc}") from None
# Default canonical config location (one file shared by all LLM launchers).
DEFAULT_CONFIG_PATH = os.path.join(
os.path.expanduser("~"), ".config", "gitea-tools", "profiles.json"
)
class ConfigError(Exception):
"""Raised for a bad config file, profile selection, or secret reference.
Messages are safe to surface: they never include file contents, tokens, or
passwords — only non-secret names/ids/positions.
"""
def config_path():
"""Return the configured JSON path, or None when the JSON layer is off."""
return (os.environ.get(ENV_CONFIG_PATH) or "").strip() or None
_active_profile_override = None
def selected_profile_name():
"""Return the selected profile name from the environment, or None."""
if _active_profile_override is not None:
return _active_profile_override
return (os.environ.get(ENV_PROFILE) or "").strip() or None
def is_runtime_switching_enabled(path=None):
"""Check if runtime profile switching is enabled in config."""
try:
config = load_config(path)
except Exception:
return False
if not config:
return False
rules = config.get("rules") or {}
if rules.get("allow_runtime_switching") is False:
return False
if config.get("allow_runtime_switching") is False:
return False
if rules.get("allow_runtime_switching") is True:
return True
if config.get("allow_runtime_switching") is True:
return True
# Default to True if multiple profiles exist in the config
profiles = config.get("profiles") or {}
if len(profiles) > 1:
return True
return False
def load_config(path=None):
"""Load and minimally validate the canonical JSON config.
Returns the parsed dict, or None when no config path is configured (so
env-only usage is never broken). Raises :class:`ConfigError` when a path is
configured but the file is missing, unreadable, not valid JSON, declares an
unsupported ``version``, or lacks a ``profiles`` object.
"""
path = path or config_path()
if not path:
return None
if not os.path.isfile(path):
raise ConfigError(f"{ENV_CONFIG_PATH} points to a missing file: {path}")
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except json.JSONDecodeError as exc:
# Report position only (msg/line/col). `from None` suppresses the
# original exception, whose `.doc` holds the raw file text — which could
# contain a token — so it never reaches a traceback.
raise ConfigError(
f"invalid JSON in {path} (line {exc.lineno}, column {exc.colno}): {exc.msg}"
) from None
except OSError as exc:
raise ConfigError(f"could not read {path}: {exc.strerror}") from None
if not isinstance(data, dict):
raise ConfigError(f"{path} must be a JSON object")
version = data.get("version")
if version is None:
# Fail closed (#103): an unversioned config is ambiguous between v1 and
# v2 shapes, so it is refused rather than guessed.
raise ConfigError(
f"{path} is missing the required 'version' field; "
f"expected one of {list(SUPPORTED_VERSIONS)}"
)
if version == 2:
return _load_v2_any(data, path)
if version != SUPPORTED_VERSION:
raise ConfigError(
f"{path} has unsupported version {version!r}; "
f"expected one of {list(SUPPORTED_VERSIONS)}"
)
if not isinstance(data.get("profiles"), dict):
raise ConfigError(f"{path} must be a JSON object with a 'profiles' object")
# #741: the v1 path returns `data` unflattened, so nothing else validates
# the cross-repository binding before gitea_auth.get_profile() reads it.
# Validate it here so every supported loader treats the field identically
# (a relative or blank path must never reach the runtime guard).
for _name, _profile in data["profiles"].items():
if isinstance(_profile, dict):
_validate_canonical_repository_root(
_name, _profile.get("canonical_repository_root")
)
return data
# ── profiles.json version 2 (#103): environment → service → identity ──────────
# v2 files are validated and *flattened* at load time into the same
# {"profiles": {...}} shape v1 consumers already understand, keyed by the
# canonical dotted address {environment}.{service}.{identity}. Two extra
# top-level keys are carried: "aliases" (exact-name compatibility selectors)
# and "unavailable" (addresses that fail closed at selection, e.g. TBD users).
def _validate_identity_auth(addr, auth):
"""Require and validate an identity 'auth' reference. Rejects inline secrets."""
if auth is None:
raise ConfigError(f"identity '{addr}' is missing an 'auth' reference")
if not isinstance(auth, dict):
raise ConfigError(f"identity '{addr}' has a non-object 'auth'")
for key in _INLINE_SECRET_KEYS:
if key in auth:
raise ConfigError(
f"identity '{addr}' auth must not contain an inline '{key}'; "
"store secrets in the keychain and reference them by id"
)
_validate_auth(addr, auth)
def _flatten_identity(env_name, svc_name, svc, ident_name, ident):
"""Validate one v2 identity and return (addr, flattened_profile).
The flattened profile is v1-shaped (base_url/auth/username/defaults) plus
v2 metadata (profile_path, environment, service, identity, role) and
normalized operation lists. Raises ConfigError on any invariant violation.
"""
addr = f"{env_name}.{svc_name}.{ident_name}"
if not isinstance(ident, dict):
raise ConfigError(f"identity '{addr}' must be a JSON object")
for key in _INLINE_SECRET_KEYS:
if key in ident:
raise ConfigError(
f"identity '{addr}' must not contain an inline '{key}'; "
"use an 'auth' reference instead"
)
_validate_identity_auth(addr, ident.get("auth"))
base_url = ident.get("base_url") or svc.get("base_url")
if not base_url:
raise ConfigError(
f"identity '{addr}' has no 'base_url' at identity or service level"
)
allowed = ident.get("allowed_operations") or []
forbidden = ident.get("forbidden_operations") or []
if not isinstance(allowed, list) or not isinstance(forbidden, list):
raise ConfigError(f"identity '{addr}' operation fields must be lists")
allowed_n = {_normalize_op(svc_name, op, addr) for op in allowed}
forbidden_n = {_normalize_op(svc_name, op, addr) for op in forbidden}
# Reviewer-identity deadlock rule (#100/#103): an identity that may approve
# or merge PRs must explicitly forbid creating PRs and pushing branches,
# so the reviewer identity can never author the PR it must review.
if allowed_n & _REVIEW_MERGE_OPS:
missing = sorted(_AUTHOR_ONLY_OPS - forbidden_n)
if missing:
raise ConfigError(
f"identity '{addr}' allows PR approve/merge but does not forbid "
f"{missing}; reviewer identities must forbid gitea.pr.create and "
"gitea.branch.push (reviewer-identity deadlock rule)"
)
profile = {
"profile_path": addr,
"environment": env_name,
"service": svc_name,
"identity": ident_name,
"base_url": base_url,
"auth": ident["auth"],
"allowed_operations": sorted(allowed_n),
"forbidden_operations": sorted(forbidden_n),
}
# Service-level defaults inherit unless the identity overrides them.
for key in ("default_owner", "default_repo", "default_org"):
value = ident.get(key, svc.get(key))
if value:
profile[key] = value
for key in ("role", "username", "execution_profile", "audit_label"):
if ident.get(key):
profile[key] = ident[key]
# #741: the cross-repository binding must survive flattening. Previously
# this key was silently dropped here, so a v2-environments namespace that
# declared canonical_repository_root fell back to the *installation* root
# and mutated Gitea-Tools instead of its target repository — a fail-open.
# Validate it exactly as the v2-contexts loader does before propagating.
_validate_canonical_repository_root(addr, ident.get("canonical_repository_root"))
if ident.get("canonical_repository_root"):
profile["canonical_repository_root"] = ident["canonical_repository_root"]
return addr, profile
def _load_v2(data, path):
"""Validate a v2 config and return the flattened, resolvable structure."""
environments = data.get("environments")
if not isinstance(environments, dict) or not environments:
raise ConfigError(
f"{path} version 2 config requires a non-empty 'environments' object"
)
profiles = {}
unavailable = {}
for env_name, env in environments.items():
if not _SEGMENT_RE.match(env_name or ""):
raise ConfigError(f"invalid environment name {env_name!r} (no dots)")
if not isinstance(env, dict):
raise ConfigError(f"environment '{env_name}' must be a JSON object")
services = env.get("services")
if not isinstance(services, dict) or not services:
raise ConfigError(
f"environment '{env_name}' requires a non-empty 'services' object"
)
for svc_name, svc in services.items():
if not _SEGMENT_RE.match(svc_name or ""):
raise ConfigError(
f"invalid service name {svc_name!r} in '{env_name}' (no dots)"
)
if not isinstance(svc, dict):
raise ConfigError(
f"service '{env_name}.{svc_name}' must be a JSON object"
)
identities = svc.get("identities")
if not isinstance(identities, dict) or not identities:
raise ConfigError(
f"service '{env_name}.{svc_name}' requires a non-empty "
"'identities' object"
)
for ident_name, ident in identities.items():
if not _SEGMENT_RE.match(ident_name or ""):
raise ConfigError(
f"invalid identity name {ident_name!r} in "
f"'{env_name}.{svc_name}' (no dots)"
)
addr, profile = _flatten_identity(
env_name, svc_name, svc, ident_name, ident
)
username = profile.get("username") or ""
if _TBD_RE.match(username):
# Fail closed at selection, without blocking every other
# identity in the file (see #103 acceptance criteria).
unavailable[addr] = (
f"identity '{addr}' username {username!r} is a TBD "
"placeholder; provision the account before use "
"(fail closed)"
)
else:
profiles[addr] = profile
aliases = data.get("aliases") or {}
if not isinstance(aliases, dict):
raise ConfigError(f"{path} 'aliases' must be a JSON object")
known = set(profiles) | set(unavailable)
for alias, target in aliases.items():
if not isinstance(target, str) or not target:
raise ConfigError(f"alias '{alias}' target must be a non-empty string")
if alias in known and alias != target:
raise ConfigError(
f"selector '{alias}' is both an alias and a profile address "
"with a different target (conflicting selector; fail closed)"
)
if target not in known:
raise ConfigError(
f"alias '{alias}' points to unknown profile '{target}'"
)
return {
"version": 2,
"profiles": profiles,
"aliases": dict(aliases),
"unavailable": unavailable,
}
# ── profiles.json version 2 *contexts* shape (#120) ───────────────────────────
# The canonical machine config groups everything by context: top-level
# "contexts" (each with a gitea block and non-Gitea "services"), flat
# "profiles" (Gitea identities pointing at a context), "projects" (local repo
# paths mapped to a context), and "rules". Every context/profile/service/
# project carries a required boolean "enabled": disabled entries are surfaced
# in audits but fail closed at selection — never a silent fallback. Loading
# flattens profiles into the same {"profiles": {...}, "unavailable": {...}}
# model v1 consumers and select_profile() already understand, and carries the
# validated "contexts"/"projects"/"rules" through for service resolution.
def _load_v2_any(data, path):
"""Dispatch a version-2 file to its shape loader; ambiguity fails closed."""
has_contexts = "contexts" in data
has_environments = "environments" in data
if has_contexts and has_environments:
raise ConfigError(
f"{path} version 2 config must not mix 'contexts' and "
"'environments' shapes (ambiguous; fail closed)"
)
if has_contexts:
return _load_v2_contexts(data, path)
return _load_v2(data, path)
def _require_enabled(kind, name, obj):
"""Return the required boolean ``enabled`` flag, failing closed."""
enabled = obj.get("enabled")
if not isinstance(enabled, bool):
raise ConfigError(
f"{kind} '{name}' requires a boolean 'enabled' flag (fail closed)"
)
return enabled
_REPO_SCOPE_RE = re.compile(r"^[^/\s]+/[^/\s]+$")
def _validate_allowed_repositories(name, raw):
"""Validate the optional per-profile repository authorization scope (#714).
``allowed_repositories`` is an authorization boundary of canonical
``owner/repository`` slugs. It is not the session binding: the verified
workspace repository selects exactly one entry at bind time. Absent means
"no repository scope configured" and is allowed, so existing profiles keep
working until an operator provisions the field.
"""
if raw is None:
return
if not isinstance(raw, list):
raise ConfigError(
f"profile '{name}' allowed_repositories must be a list of "
"'owner/repository' strings"
)
for entry in raw:
if not isinstance(entry, str) or not _REPO_SCOPE_RE.match(entry.strip()):
raise ConfigError(
f"profile '{name}' allowed_repositories entry {entry!r} is not "
"a canonical 'owner/repository' slug"
)
def _validate_canonical_repository_root(name, raw):
"""Validate the optional per-profile canonical repository root (#706).
``canonical_repository_root`` binds a cross-repository namespace to the
working root of its target repository (separate from the immutable
Gitea-Tools install checkout). It is an absolute filesystem path; existence
and git identity are validated at bind time by the runtime guard, not here
(config validation stays filesystem-independent). Absent means the
single-repo default and is allowed.
"""
if raw is None:
return
if not isinstance(raw, str) or not raw.strip():
raise ConfigError(
f"profile '{name}' canonical_repository_root must be a non-empty "
"absolute path string to the target repository working root"
)
if not os.path.isabs(raw.strip()):
raise ConfigError(
f"profile '{name}' canonical_repository_root {raw!r} must be an "
"absolute path"
)
def _reject_inline_secrets(kind, name, obj):
for key in _INLINE_SECRET_KEYS:
if key in obj:
raise ConfigError(
f"{kind} '{name}' must not contain an inline '{key}'; "
"store secrets in the keychain and reference them by id"
)
def _validate_context_service(ctx_name, svc_name, svc):
"""Validate one context service entry (auth reference only, no secrets)."""
addr = f"{ctx_name}.{svc_name}"
if not isinstance(svc, dict):
raise ConfigError(f"service '{addr}' must be a JSON object")
_require_enabled("service", addr, svc)
_reject_inline_secrets("service", addr, svc)
if "auth" in svc:
_validate_auth(addr, svc["auth"])
def _load_v2_contexts(data, path):
"""Validate a v2 contexts-shape config and return the resolvable structure."""
contexts = data.get("contexts")
if not isinstance(contexts, dict) or not contexts:
raise ConfigError(
f"{path} version 2 contexts config requires a non-empty "
"'contexts' object"
)
for ctx_name, ctx in contexts.items():
if not _PROFILE_NAME_RE.match(ctx_name or ""):
raise ConfigError(f"invalid context name {ctx_name!r}")
if not isinstance(ctx, dict):
raise ConfigError(f"context '{ctx_name}' must be a JSON object")
_require_enabled("context", ctx_name, ctx)
gitea = ctx.get("gitea")
if gitea is not None:
if not isinstance(gitea, dict):
raise ConfigError(
f"context '{ctx_name}' has a non-object 'gitea' block")
_require_enabled("service", f"{ctx_name}.gitea", gitea)
_reject_inline_secrets("service", f"{ctx_name}.gitea", gitea)
services = ctx.get("services") or {}
if not isinstance(services, dict):
raise ConfigError(
f"context '{ctx_name}' has a non-object 'services' block")
for svc_name, svc in services.items():
_validate_context_service(ctx_name, svc_name, svc)
raw_profiles = data.get("profiles")
if not isinstance(raw_profiles, dict) or not raw_profiles:
raise ConfigError(
f"{path} version 2 contexts config requires a non-empty "
"'profiles' object"
)
profiles = {}
unavailable = {}
for name, raw in raw_profiles.items():
if not is_valid_profile_name(name):
raise ConfigError(f"invalid profile name {name!r}")
if not isinstance(raw, dict):
raise ConfigError(f"profile '{name}' must be a JSON object")
enabled = _require_enabled("profile", name, raw)
_reject_inline_secrets("profile", name, raw)
_validate_identity_auth(name, raw.get("auth"))
ctx_name = raw.get("context")
if ctx_name not in contexts:
raise ConfigError(
f"profile '{name}' references unknown context {ctx_name!r}")
context = contexts[ctx_name]
allowed = raw.get("allowed_operations") or []
forbidden = raw.get("forbidden_operations") or []
if not isinstance(allowed, list) or not isinstance(forbidden, list):
raise ConfigError(f"profile '{name}' operation fields must be lists")
_validate_allowed_repositories(name, raw.get("allowed_repositories"))
_validate_canonical_repository_root(
name, raw.get("canonical_repository_root")
)
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
if allowed_n & _REVIEW_MERGE_OPS:
missing = sorted(_AUTHOR_ONLY_OPS - forbidden_n)
if missing:
raise ConfigError(
f"profile '{name}' allows PR approve/merge but does not "
f"forbid {missing}; reviewer identities must forbid "
"gitea.pr.create and gitea.branch.push "
"(reviewer-identity deadlock rule)"
)
profile = dict(raw)
profile["allowed_operations"] = sorted(allowed_n)
profile["forbidden_operations"] = sorted(forbidden_n)
gitea = context.get("gitea") or {}
if not profile.get("base_url") and gitea.get("enabled"):
profile["base_url"] = gitea.get("base_url")
username = profile.get("username") or ""
if not enabled:
unavailable[name] = (
f"profile '{name}' is disabled (enabled: false); defined but "
"unavailable for action — refusing, no fallback"
)
elif not context.get("enabled"):
unavailable[name] = (
f"profile '{name}' belongs to context '{ctx_name}' which is "
"disabled (enabled: false); refusing, no fallback"
)
elif not profile.get("base_url"):
unavailable[name] = (
f"profile '{name}' has no usable base_url (none set and the "
f"context '{ctx_name}' gitea service is disabled or has none); "
"fail closed"
)
elif _TBD_RE.match(username):
unavailable[name] = (
f"profile '{name}' username {username!r} is a TBD placeholder; "
"provision the account before use (fail closed)"
)
else:
profiles[name] = profile
continue
# Unavailable profiles keep their (secret-free) body for audits only.
profile["_unavailable_reason"] = unavailable[name]
profiles.setdefault("_audit_only", {})
profiles["_audit_only"][name] = profile
projects = data.get("projects") or {}
if not isinstance(projects, dict):
raise ConfigError(f"{path} 'projects' must be a JSON object")
for proj_path, proj in projects.items():
if not isinstance(proj, dict):
raise ConfigError(f"project '{proj_path}' must be a JSON object")
_require_enabled("project", proj_path, proj)
if proj.get("context") not in contexts:
raise ConfigError(
f"project '{proj_path}' references unknown context "
f"{proj.get('context')!r}"
)
rules = data.get("rules") or {}
if not isinstance(rules, dict):
raise ConfigError(f"{path} 'rules' must be a JSON object")
audit_only = profiles.pop("_audit_only", {})
return {
"version": 2,
"shape": "contexts",
"profiles": profiles,
"unavailable": unavailable,
"audit_only_profiles": audit_only,
"contexts": contexts,
"projects": projects,
"rules": rules,
}
def resolve_service(config, context_name, service_name):
"""Return one context service's config for *internal* MCP use.
The returned dict includes the endpoint base_url and the keychain auth
*reference* — both are for MCP-internal resolution only and must never be
echoed into normal LLM-facing output (see audit_config/service_summaries).
Fails closed on an unknown or disabled context/service; never falls back
to another service.
"""
contexts = (config or {}).get("contexts")
if not isinstance(contexts, dict):
raise ConfigError(
"service resolution requires a version 2 contexts config")
ctx = contexts.get(context_name)
if ctx is None:
raise ConfigError(
f"unknown context '{context_name}' (fail closed, no fallback)")
if not ctx.get("enabled"):
raise ConfigError(
f"context '{context_name}' is disabled; its services are defined "
"but unavailable for action (no fallback)"
)
if service_name == "gitea":
service = ctx.get("gitea")
else:
service = (ctx.get("services") or {}).get(service_name)
if service is None:
raise ConfigError(
f"unknown service '{service_name}' in context '{context_name}' "
"(fail closed, no fallback)"
)
if not service.get("enabled"):
raise ConfigError(
f"service '{context_name}.{service_name}' is disabled; defined "
"but unavailable for action — refusing, no fallback"
)
return dict(service)
def project_for_path(config, path):
"""Map a local project *path* to its context entry, failing closed.
Returns None when the path is not configured (feature off for that repo).
Raises :class:`ConfigError` when the project or its context is disabled —
a configured-but-disabled project must never be acted on.
"""
projects = (config or {}).get("projects") or {}
project = projects.get(path)
if project is None:
return None
if not project.get("enabled"):
raise ConfigError(
f"project '{path}' is disabled (enabled: false); refusing, "
"no fallback"
)
contexts = (config or {}).get("contexts") or {}
ctx = contexts.get(project.get("context")) or {}
if not ctx.get("enabled"):
raise ConfigError(
f"project '{path}' maps to context '{project.get('context')}' "
"which is disabled; refusing, no fallback"
)
return dict(project)
def _audit_profile_entry(name, profile, enabled, reveal_endpoints):
"""One LLM-safe audit row: no endpoint URLs, no keychain ids, no tokens."""
auth = profile.get("auth") if isinstance(profile, dict) else None
entry = {
"name": name,
"enabled": enabled,
"context": profile.get("context") or profile.get("environment"),
"role": profile.get("role"),
"username": profile.get("username"),
"auth": (auth or {}).get("type") if isinstance(auth, dict) else None,
}
reason = profile.get("_unavailable_reason")
if reason:
entry["reason"] = reason
if reveal_endpoints:
entry["base_url"] = profile.get("base_url")
entry["auth_source"] = auth_source_name(profile)
return entry
def audit_config(config, reveal_endpoints=False):
"""Report enabled/disabled profiles and services without secrets.
Default output is LLM-safe: names, contexts, enabled state, capability
labels, and the auth *type* only — never endpoint URLs, keychain ids,
token values, or auth source names. ``reveal_endpoints=True`` is the
explicit admin/debug opt-in for local diagnostics: it adds base URLs and
non-secret auth source names (``keychain:<id>`` / env var name). Token
values are never included on any path.
"""
if config is None:
return {"version": None, "profiles": [], "services": []}
report = {
"version": config.get("version"),
"shape": config.get("shape") or ("environments"
if config.get("aliases") is not None
else "profiles"),
"profiles": [],
"services": [],
}
for name, profile in (config.get("profiles") or {}).items():
if not isinstance(profile, dict):
continue
report["profiles"].append(_audit_profile_entry(
name, profile, True, reveal_endpoints))
for name, profile in (config.get("audit_only_profiles") or {}).items():
report["profiles"].append(_audit_profile_entry(
name, profile, False, reveal_endpoints))
for ctx_name, ctx in (config.get("contexts") or {}).items():
ctx_enabled = bool(ctx.get("enabled"))
for svc_name, svc in (ctx.get("services") or {}).items():
entry = {
"context": ctx_name,
"name": svc_name,
"kind": svc.get("kind"),
"label": svc.get("label"),
"enabled": ctx_enabled and bool(svc.get("enabled")),
"capabilities": list(svc.get("capabilities") or []),
"auth": (svc.get("auth") or {}).get("type"),
}
if reveal_endpoints:
entry["base_url"] = svc.get("base_url")
entry["auth_source"] = auth_source_name(svc)
report["services"].append(entry)
return report
def service_summaries(config, auth_check=None):
"""Safe one-line service summaries for LLM sessions.
Each line reports label + state only (e.g. ``PRGS Jenkins: enabled,
read-only, authenticated`` / ``PRGS Sentry: disabled``) — never endpoint
URLs, keychain ids, or token values. *auth_check* is a callable taking the
service dict and returning True when its credential resolves; it defaults
to a local keychain presence check and its result is reported only as
``authenticated`` / ``no credential``.
"""
if auth_check is None:
def auth_check(service):
auth = service.get("auth") or {}
if auth.get("type") == "keychain":
return _keychain_token(auth.get("id")) is not None
if auth.get("type") == "env":
return bool(os.environ.get(auth.get("name") or ""))
return False
lines = []
for ctx_name, ctx in (config.get("contexts") or {}).items():
ctx_enabled = bool(ctx.get("enabled"))
for svc_name, svc in (ctx.get("services") or {}).items():
label = svc.get("label") or f"{ctx_name} {svc_name}"
if not (ctx_enabled and svc.get("enabled")):
lines.append(f"{label}: disabled")
continue
caps = list(svc.get("capabilities") or [])
cap_part = "read-only" if caps == ["read"] else ", ".join(caps)
auth_part = "authenticated" if auth_check(svc) else "no credential"
parts = ["enabled"] + ([cap_part] if cap_part else []) + [auth_part]
lines.append(f"{label}: " + ", ".join(parts))
return lines
def _validate_auth(name, auth):
"""Validate a profile's optional ``auth`` reference. Never echoes secrets."""
if auth is None:
return
if not isinstance(auth, dict):
raise ConfigError(f"profile '{name}' has a non-object 'auth'")
atype = auth.get("type")
if atype not in _AUTH_TYPES:
raise ConfigError(
f"profile '{name}' has invalid auth type {atype!r}; "
f"expected one of {list(_AUTH_TYPES)}"
)
if atype == "keychain" and not auth.get("id"):
raise ConfigError(f"profile '{name}' keychain auth requires an 'id'")
if atype == "env" and not auth.get("name"):
raise ConfigError(f"profile '{name}' env auth requires a 'name'")
def select_profile(config, name=None):
"""Return the selected profile dict from a loaded *config*.
Returns None when *config* is None. Raises :class:`ConfigError` when no
profile is selected, the selected profile is unknown or not an object, the
profile embeds a raw ``token``/``password``, or its ``auth`` reference is
malformed.
"""
if config is None:
return None
profiles = config.get("profiles", {})
aliases = config.get("aliases") or {}
unavailable = config.get("unavailable") or {}
name = name or selected_profile_name()
available = sorted(set(profiles) | set(aliases))
if not name:
raise ConfigError(
f"{ENV_CONFIG_PATH} is set but {ENV_PROFILE} is not; "
f"available profiles: {available}"
)
# Strict resolution order (#103): exact alias → exact profile address →
# fail closed. No fuzzy matching, no partial matches, no defaults.
resolved = aliases.get(name, name)
if resolved in unavailable:
raise ConfigError(unavailable[resolved])
if resolved not in profiles:
raise ConfigError(
f"profile '{name}' not found in config; available profiles: {available}"
)
profile = profiles[resolved]
if not isinstance(profile, dict):
raise ConfigError(f"profile '{name}' must be a JSON object")
for secret_key in ("token", "password"):
if secret_key in profile:
# Never accept (or echo) an inline secret; require an auth reference.
raise ConfigError(
f"profile '{name}' must not contain an inline '{secret_key}'; "
"use an 'auth' reference ({\"type\": \"keychain\"|\"env\", ...}) instead"
)
_validate_auth(name, profile.get("auth"))
return profile
def resolve_profile(path=None, name=None):
"""Load the config and return the selected profile dict, or None if off."""
return select_profile(load_config(path), name)
def auth_source_name(profile):
"""Return a *non-secret* name for a profile's token source, or None.
For env auth this is the env var name; for keychain auth, ``keychain:<id>``.
Safe to surface in profile metadata (never the token value).
"""
if not profile:
return None
auth = profile.get("auth")
if not isinstance(auth, dict):
return None
if auth.get("type") == "env":
return auth.get("name")
if auth.get("type") == "keychain":
return f"keychain:{auth.get('id')}"
return None
def _keychain_token(item_id):
"""Read a token from the macOS keychain by service *item_id*.
Returns the secret string, or None if it cannot be found. Never logs the
value; failures are swallowed so the caller can raise a safe error.
"""
try:
proc = subprocess.run(
["security", "find-generic-password", "-s", item_id, "-w"],
capture_output=True, text=True,
)
except (OSError, subprocess.SubprocessError):
return None
if proc.returncode != 0:
return None
return proc.stdout.strip() or None
def resolve_token(profile, keychain_lookup=_keychain_token):
"""Resolve the token for *profile* via its ``auth`` reference.
Returns None when *profile* is None or has no ``auth``. Raises
:class:`ConfigError` when the reference cannot be resolved (env var unset or
keychain item missing). Never accepts an inline token and never logs, echoes,
or includes the secret *value* in any error. *keychain_lookup* is injectable
for testing.
"""
if not profile:
return None
auth = profile.get("auth")
if not isinstance(auth, dict):
return None
atype = auth.get("type")
if atype == "env":
env_name = auth.get("name")
value = os.environ.get(env_name) if env_name else None
if not value:
raise ConfigError(
f"auth env var '{env_name}' referenced by the profile is not set"
)
return value
if atype == "keychain":
item_id = auth.get("id")
value = keychain_lookup(item_id) if item_id else None
if not value:
raise ConfigError(
f"keychain item '{item_id}' referenced by the profile was not found"
)
return value
raise ConfigError(f"unsupported auth type {atype!r} in the selected profile")
# ── Config authoring helpers (used by the interactive menu; pure + testable) ────
def is_valid_profile_name(name):
"""True if *name* is a safe profile key (alnum, dot, dash, underscore)."""
return bool(name) and bool(_PROFILE_NAME_RE.match(name))
def keychain_auth(item_id):
"""Build a keychain auth reference."""
return {"type": "keychain", "id": item_id}
def env_auth(var_name):
"""Build an env auth reference."""
return {"type": "env", "name": var_name}
def build_profile(*, base_url, auth, username=None, default_owner=None,
default_repo=None, execution_profile=None):
"""Assemble a profile dict, omitting empty optional fields. No secrets."""
profile = {"base_url": base_url, "auth": auth}
if username:
profile["username"] = username
if default_owner:
profile["default_owner"] = default_owner
if default_repo:
profile["default_repo"] = default_repo
if execution_profile:
profile["execution_profile"] = execution_profile
return profile
def empty_config():
"""Return a fresh, valid canonical config with no profiles."""
return {"version": SUPPORTED_VERSION, "profiles": {}}
def validate_config(config):
"""Return a list of human-readable problems with *config* (empty = valid).
Never includes secret material — profiles carry only auth *references*.
"""
problems = []
if not isinstance(config, dict):
return ["config is not a JSON object"]
version = config.get("version")
if version is None:
problems.append(
f"missing required 'version' (expected one of {list(SUPPORTED_VERSIONS)})"
)
elif version == 2:
# v2 validation is all-or-nothing via the loader's invariants.
try:
_load_v2_any(config, "<config>")
except ConfigError as exc:
problems.append(str(exc))
return problems
elif version != SUPPORTED_VERSION:
problems.append(
f"unsupported version {version!r} (expected one of {list(SUPPORTED_VERSIONS)})"
)
profiles = config.get("profiles")
if not isinstance(profiles, dict):
problems.append("missing 'profiles' object")
return problems
for name, profile in profiles.items():
if not is_valid_profile_name(name):
problems.append(f"invalid profile name {name!r}")
if not isinstance(profile, dict):
problems.append(f"profile '{name}' is not an object")
continue
if not profile.get("base_url"):
problems.append(f"profile '{name}' is missing 'base_url'")
for secret_key in ("token", "password"):
if secret_key in profile:
problems.append(
f"profile '{name}' has an inline '{secret_key}' (use an auth reference)"
)
try:
_validate_auth(name, profile.get("auth"))
except ConfigError as exc:
problems.append(str(exc))
else:
if profile.get("auth") is None:
problems.append(f"profile '{name}' is missing an 'auth' reference")
return problems
def add_profile(config, name, profile):
"""Return a copy of *config* with *profile* added under *name*.
Preserves existing profiles. Raises :class:`ConfigError` on an invalid name,
a duplicate name, or an invalid profile.
"""
if not is_valid_profile_name(name):
raise ConfigError(
f"invalid profile name {name!r}; use letters, digits, '.', '-', '_'"
)
profiles = dict(config.get("profiles") or {})
if name in profiles:
raise ConfigError(f"profile '{name}' already exists; edit or remove it first")
candidate = {"version": config.get("version", SUPPORTED_VERSION),
"profiles": {**profiles, name: profile}}
# Validate just this profile (reuse select_profile's checks).
select_profile(candidate, name)
if not profile.get("base_url"):
raise ConfigError(f"profile '{name}' is missing 'base_url'")
return candidate
def upsert_profile(config, name, profile):
"""Like :func:`add_profile` but replaces an existing profile (for edits)."""
if not is_valid_profile_name(name):
raise ConfigError(f"invalid profile name {name!r}")
profiles = dict(config.get("profiles") or {})
profiles[name] = profile
candidate = {"version": config.get("version", SUPPORTED_VERSION),
"profiles": profiles}
select_profile(candidate, name)
return candidate
def remove_profile(config, name):
"""Return a copy of *config* without profile *name*."""
profiles = dict(config.get("profiles") or {})
if name not in profiles:
raise ConfigError(f"profile '{name}' not found")
del profiles[name]
return {"version": config.get("version", SUPPORTED_VERSION), "profiles": profiles}
def save_config(config, path=None):
"""Atomically write *config* to *path* as pretty JSON, creating parent dirs.
Writes a temp file in the same directory then ``os.replace``s it into place,
so a crash mid-write never truncates an existing config.
"""
path = path or config_path() or DEFAULT_CONFIG_PATH
directory = os.path.dirname(os.path.abspath(path))
os.makedirs(directory, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".profiles-", suffix=".json")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(config, fh, indent=2, sort_keys=True)
fh.write("\n")
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
return path
def server_command():
"""Return (command, args) that launch this repo's MCP server."""
root = os.path.dirname(os.path.abspath(__file__))
python = os.path.join(root, "venv", "bin", "python3")
if not os.path.exists(python):
python = sys.executable
return python, [os.path.join(root, "mcp_server.py")]
def launcher_entry(profile_name, config_path=None):
"""Return a thin MCP launcher entry for *profile_name*.
Contains only command/args and the two GITEA_MCP_* env vars — never a token
or password. Suitable for Claude / Gemini / Codex ``mcpServers`` blocks.
"""
command, args = server_command()
return {
"gitea-tools": {
"command": command,
"args": args,
"env": {
"GITEA_MCP_CONFIG": config_path or DEFAULT_CONFIG_PATH,
"GITEA_MCP_PROFILE": profile_name,
},
}
}
def keychain_set(item_id, token, account=None, runner=subprocess.run):
"""Store *token* in the macOS keychain under service *item_id*.
The token is passed to ``security`` as an argument only; it is never
returned, printed, or logged here. *runner* is injectable for testing.
Raises :class:`ConfigError` on failure (without echoing the token).
"""
if not item_id:
raise ConfigError("keychain item id is required")
if not token:
raise ConfigError("refusing to store an empty token")
account = account or os.environ.get("USER") or "gitea-tools"
cmd = ["security", "add-generic-password", "-U",
"-s", item_id, "-a", account, "-w", token]
try:
proc = runner(cmd, capture_output=True, text=True)
except (OSError, subprocess.SubprocessError) as exc:
raise ConfigError(f"could not run keychain store for '{item_id}'") from exc
if getattr(proc, "returncode", 1) != 0:
raise ConfigError(f"keychain store failed for item '{item_id}'")
return item_id
if __name__ == "__main__": # pragma: no cover - thin CLI dispatch
if len(sys.argv) > 1 and sys.argv[1] == "menu":
import gitea_config_menu
raise SystemExit(gitea_config_menu.main(sys.argv[2:]))
if len(sys.argv) > 1 and sys.argv[1] == "audit":
# Local admin/debug diagnostics (#120). --reveal-endpoints is the
# explicit opt-in that adds base URLs and non-secret auth source
# names; token values are never printed on any path.
try:
config = load_config(config_path() or DEFAULT_CONFIG_PATH)
report = audit_config(
config, reveal_endpoints="--reveal-endpoints" in sys.argv[2:])
report["summaries"] = service_summaries(config)
except ConfigError as exc:
print(f"config error: {exc}", file=sys.stderr)
raise SystemExit(1)
print(json.dumps(report, indent=2))
raise SystemExit(0)
print("usage: python gitea_config.py menu | audit [--reveal-endpoints]",
file=sys.stderr)
raise SystemExit(2)