3 Commits

Author SHA1 Message Date
sysadmin 9d6a2e0a5f docs: re-land release version SOP with v1.1.0 audit lessons (#111) 2026-07-03 03:24:26 -04:00
sysadmin 205f089c44 Merge pull request 'feat: profiles.json v2 contexts shape with enabled enforcement and LLM-safe output (#120)' (#121) from feat/issue-120-profiles-v2-contexts into master 2026-07-03 01:36:21 -05:00
sysadmin ff920a6496 feat: load profiles.json v2 contexts shape with enabled enforcement and LLM-safe output (#120)
Support the canonical contexts-shape version 2 config (contexts / profiles /
projects / rules) alongside the existing environments shape and v1:

- Require a boolean 'enabled' on every context, profile, service, and
  project. Disabled entries are surfaced in audits but fail closed at
  selection/resolution — never a silent fallback to another profile,
  service, or credential source.
- Resolve the active identity from GITEA_MCP_PROFILE via the existing
  select_profile path; profile base_url falls back to the context's enabled
  gitea block.
- Add resolve_service() and project_for_path() for context service and
  project-to-context resolution (internal use; fail closed on disabled).
- get_auth_header now propagates ConfigError when GITEA_MCP_CONFIG is set
  instead of silently degrading to Basic auth.
- Hide endpoint URLs and keychain ids from normal LLM-facing output:
  gitea_whoami / gitea_get_profile report logical names and auth status
  only; new gitea_audit_config tool reports enabled/disabled state and safe
  one-line service summaries. The GITEA_MCP_REVEAL_ENDPOINTS opt-in (and
  'python3 gitea_config.py audit --reveal-endpoints' locally) restores
  endpoints and auth source names for admin diagnostics; token values are
  never printed on any path.
- Ship gitea-mcp.v2-contexts.example.json (synthetic values) and validate
  it in tests.

Implements #120

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 02:19:39 -04:00
8 changed files with 1127 additions and 22 deletions
+1
View File
@@ -6,6 +6,7 @@ __pycache__/
# Real JSON runtime-profile configs may reference private hosts; keep only the example.
gitea-mcp*.json
!gitea-mcp.example.json
!gitea-mcp.v2-contexts.example.json
.vscode/
graphify-out/
branches/
+80
View File
@@ -0,0 +1,80 @@
{
"version": 2,
"contexts": {
"example-context": {
"enabled": true,
"label": "Example environment",
"description": "One deployment environment: its Gitea plus non-Gitea services.",
"default_owner": "Example-Org",
"gitea": {
"enabled": true,
"kind": "gitea",
"base_url": "https://gitea.example.invalid"
},
"services": {
"jenkins": {
"enabled": true,
"kind": "jenkins",
"label": "Example Jenkins",
"base_url": "https://jenkins.example.invalid",
"auth": { "type": "keychain", "id": "example-jenkins-token" },
"capabilities": ["read"]
},
"glitchtip": {
"enabled": false,
"kind": "glitchtip",
"label": "Example GlitchTip (disabled: defined but unavailable)",
"base_url": "",
"auth": { "type": "keychain", "id": "example-glitchtip-token" },
"capabilities": ["read"],
"allow_raw_events": false
}
}
}
},
"profiles": {
"example-author": {
"enabled": true,
"context": "example-context",
"role": "author",
"username": "author-user",
"execution_profile": "example-author",
"audit_label": "example-author",
"auth": { "type": "keychain", "id": "example-gitea-author-token" },
"allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment"],
"forbidden_operations": ["approve", "request_changes", "merge"]
},
"example-reviewer": {
"enabled": true,
"context": "example-context",
"role": "reviewer",
"username": "reviewer-user",
"execution_profile": "example-reviewer",
"audit_label": "example-reviewer",
"auth": { "type": "keychain", "id": "example-gitea-reviewer-token" },
"allowed_operations": ["read", "review", "comment", "approve", "request_changes", "merge"],
"forbidden_operations": ["branch", "commit", "push", "open_pr"]
}
},
"projects": {
"/absolute/path/to/local/repo": {
"enabled": true,
"context": "example-context",
"default_owner": "Example-Org",
"default_repo": "Example-Repo",
"default_author_profile": "example-author",
"default_reviewer_profile": "example-reviewer"
}
},
"rules": {
"disabled_behavior": "Defined but unavailable for action. MCP tools may report disabled entries during audits, but must not use them automatically.",
"no_silent_fallback": true,
"tokens_in_json": false,
"token_storage": "keychain",
"identity_must_match_task": true,
"same_username_cannot_review_own_pr": true,
"hide_service_urls_from_llm": true,
"hide_keychain_ids_from_llm": true,
"mcp_resolves_endpoints": true
}
}
+7 -3
View File
@@ -123,13 +123,17 @@ def get_auth_header(host):
token = os.environ.get("GITEA_TOKEN")
# 3. Fall back to a JSON runtime-profile token reference (token_env).
# Explicit env tokens above take precedence. A broken config never breaks
# auth here — it fails closed to "no token"; the clear error surfaces via
# get_profile() / startup instead.
# Explicit env tokens above take precedence. When GITEA_MCP_CONFIG is
# configured, a broken config or unresolvable profile/credential fails
# closed here (no silent fallback to Basic auth or another source,
# #120). Without a configured JSON layer, env-only behaviour is
# unchanged.
if not token:
try:
token = gitea_config.resolve_token(gitea_config.resolve_profile())
except gitea_config.ConfigError:
if gitea_config.config_path():
raise
token = None
if token:
+381 -3
View File
@@ -83,6 +83,12 @@ _MINIMAL_GITEA_OP_MAP = {
"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"})
@@ -169,7 +175,7 @@ def load_config(path=None):
f"expected one of {list(SUPPORTED_VERSIONS)}"
)
if version == 2:
return _load_v2(data, path)
return _load_v2_any(data, path)
if version != SUPPORTED_VERSION:
raise ConfigError(
f"{path} has unsupported version {version!r}; "
@@ -345,6 +351,363 @@ def _load_v2(data, path):
}
# ── 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
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")
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:
@@ -534,7 +897,7 @@ def validate_config(config):
elif version == 2:
# v2 validation is all-or-nothing via the loader's invariants.
try:
_load_v2(config, "<config>")
_load_v2_any(config, "<config>")
except ConfigError as exc:
problems.append(str(exc))
return problems
@@ -691,5 +1054,20 @@ 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:]))
print("usage: python gitea_config.py menu", file=sys.stderr)
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)
+62 -10
View File
@@ -43,6 +43,16 @@ from gitea_auth import ( # noqa: E402
get_profile,
)
import gitea_audit # noqa: E402
import gitea_config # noqa: E402
def _reveal_endpoints() -> bool:
"""Admin/debug opt-in (#120): include endpoint URLs and token source
names in tool output. Off by default so normal LLM-facing responses
expose only logical names and status. Never affects token values, which
are excluded on every path."""
return (os.environ.get("GITEA_MCP_REVEAL_ENDPOINTS") or "").strip().lower() \
in ("1", "true", "yes")
mcp = FastMCP("gitea-tools", instructions=(
"Gitea issue tracker and PR management for dadeschools and prgs instances. "
@@ -1382,21 +1392,26 @@ def gitea_whoami(
"Verify the configured token is valid for this instance."
)
# Runtime profile metadata is non-secret (name + allowed op categories).
# The token is resolved separately and is never included here.
# The token is resolved separately and is never included here. Endpoint
# URLs stay out of normal LLM-facing output (#120): the logical remote
# name is the addressing surface; 'server' appears only under the
# GITEA_MCP_REVEAL_ENDPOINTS admin opt-in.
profile = get_profile()
return {
result = {
"authenticated": True,
"username": data.get("login"),
"display_name": data.get("full_name") or None,
"user_id": data.get("id"),
"email": data.get("email") or None,
"server": f"https://{h}",
"remote": remote,
"profile": {
"profile_name": profile["profile_name"],
"allowed_operations": profile["allowed_operations"],
},
}
if _reveal_endpoints():
result["server"] = f"https://{h}"
return result
@@ -1427,9 +1442,11 @@ def gitea_get_profile(
Read-only. Reports the non-secret configuration of the running MCP
process (profile name, allowed/forbidden operation categories, audit
label, token *source name*, base URL) plus the resolved server for the
given remote. Optionally resolves the authenticated username via
``gitea_whoami``'s endpoint so an LLM can see who this runtime acts as.
label, auth *status*). Endpoint URLs and token source names are hidden
from normal output (#120) and appear only under the
GITEA_MCP_REVEAL_ENDPOINTS admin opt-in. Optionally resolves the
authenticated username via ``gitea_whoami``'s endpoint so an LLM can see
who this runtime acts as.
This tool never mutates Gitea and never approves, merges, comments, or
creates anything. It never returns the token value, Authorization header,
@@ -1447,18 +1464,25 @@ def gitea_get_profile(
'verified', 'unknown', 'unavailable', or 'not_resolved'.
"""
profile = get_profile()
reveal = _reveal_endpoints()
result = {
"profile_name": profile["profile_name"],
"allowed_operations": profile["allowed_operations"],
"forbidden_operations": profile["forbidden_operations"],
"audit_label": profile["audit_label"],
"token_source_name": profile["token_source_name"],
"base_url": profile["base_url"],
# Auth is reported as a status only (#120): the token source *name*
# (env var name / keychain id) joins endpoint URLs behind the
# GITEA_MCP_REVEAL_ENDPOINTS admin opt-in. Token values never appear.
"auth_status": ("configured" if profile["token_source_name"]
else "unconfigured"),
"remote": remote if remote in REMOTES else None,
"server": None,
"authenticated_username": None,
"identity_status": "not_resolved",
}
if reveal:
result["token_source_name"] = profile["token_source_name"]
result["base_url"] = profile["base_url"]
result["server"] = None
if remote not in REMOTES:
# Mark ambiguity rather than raising: the tool stays inspectable.
@@ -1467,7 +1491,8 @@ def gitea_get_profile(
return result
h = host or REMOTES[remote]["host"]
result["server"] = f"https://{h}"
if reveal:
result["server"] = f"https://{h}"
if resolve_identity:
try:
@@ -1487,6 +1512,33 @@ def gitea_get_profile(
return result
@mcp.tool()
def gitea_audit_config() -> dict:
"""Audit the configured profiles/services: enabled state, no secrets.
Read-only and local-only: loads the canonical profiles.json named by
GITEA_MCP_CONFIG and reports profile/service names, contexts, enabled
state, capabilities, auth *status*, and one-line service summaries (e.g.
``PRGS Jenkins: enabled, read-only, authenticated``). Disabled entries
are listed so they can be audited, but the server refuses to act with
them and never falls back to another profile or service.
Never includes endpoint URLs, keychain ids, token source names, or token
values. Endpoint-revealing diagnostics exist only in the local admin CLI
(``python3 gitea_config.py audit --reveal-endpoints``), never over MCP.
"""
config = gitea_config.load_config()
if config is None:
return {
"configured": False,
"message": "No GITEA_MCP_CONFIG configured; env-only mode.",
}
report = gitea_config.audit_config(config)
report["configured"] = True
report["summaries"] = gitea_config.service_summaries(config)
return report
@mcp.tool()
def gitea_mark_issue(
issue_number: int,
+5 -3
View File
@@ -284,11 +284,13 @@ class TestAuthIntegration(_ConfigBase):
self.assertEqual(header, "token process-token")
def test_auth_header_unresolvable_ref_fails_closed(self):
# env token ref points at an unset var -> ConfigError inside resolve is
# swallowed to "no token"; auth falls through to (mocked-empty) basic.
# env token ref points at an unset var -> with GITEA_MCP_CONFIG set the
# ConfigError propagates (fail closed, #120): no silent fallback to
# Basic auth or another credential source.
with patch.dict(os.environ, self._env("mdcps-env"), clear=True):
with patch("gitea_auth.get_credentials", return_value=("", "")):
self.assertIsNone(gitea_auth.get_auth_header("gitea.example.com"))
with self.assertRaises(gitea_config.ConfigError):
gitea_auth.get_auth_header("gitea.example.com")
# ---------------------------------------------------------------------------
+446
View File
@@ -0,0 +1,446 @@
"""Tests for profiles.json version 2 *contexts* shape (#120).
The canonical machine config uses ``contexts`` / ``profiles`` / ``projects`` /
``rules`` with explicit ``enabled`` flags. Covers: loading + active-profile
resolution via GITEA_MCP_PROFILE, fail-closed refusal of disabled profiles /
contexts / services / projects, project-to-context mapping, base-URL fallback
from the context's gitea block, keychain-only auth references, LLM-safe audit
output (no endpoint URLs, no keychain ids, no tokens) with an explicit
admin/debug opt-in, v1 compatibility, and the no-silent-fallback rule in
gitea_auth.get_auth_header. No network, no real secrets.
"""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_config # noqa: E402
import gitea_auth # noqa: E402
FAKE_TOKEN = "fake-token-for-tests" # not a real credential
def contexts_config():
"""A fresh, valid v2 contexts-shape config with enabled/disabled entries."""
return {
"version": 2,
"contexts": {
"prgs": {
"enabled": True,
"label": "Local / PRGS",
"default_owner": "Scaled-Tech-Consulting",
"gitea": {
"enabled": True,
"kind": "gitea",
"base_url": "https://gitea.prgs.cc",
},
"services": {
"jenkins": {
"enabled": True,
"kind": "jenkins",
"label": "PRGS Jenkins",
"base_url": "https://jenkins.prgs.cc",
"auth": {"type": "keychain", "id": "prgs-jenkins-token"},
"capabilities": ["read"],
},
"sentry": {
"enabled": False,
"kind": "sentry",
"label": "PRGS Sentry",
"base_url": "",
"auth": {"type": "keychain", "id": "prgs-sentry-token"},
"capabilities": ["read"],
},
},
},
"lab": {
"enabled": False,
"gitea": {"enabled": False, "kind": "gitea", "base_url": ""},
"services": {
"jenkins": {
"enabled": False,
"kind": "jenkins",
"label": "Lab Jenkins",
"base_url": "http://localhost:8080",
"auth": {"type": "keychain", "id": "lab-jenkins-token"},
"capabilities": ["read"],
},
},
},
},
"profiles": {
"prgs-author": {
"enabled": True,
"context": "prgs",
"role": "author",
"username": "jcwalker3",
"execution_profile": "prgs-author",
"audit_label": "prgs-author",
"base_url": "https://gitea.prgs.cc",
"auth": {"type": "keychain", "id": "prgs-gitea-author-token"},
"allowed_operations": [
"read", "branch", "commit", "push", "open_pr", "comment",
],
"forbidden_operations": [
"approve", "request_changes", "merge",
],
},
"prgs-reviewer": {
"enabled": True,
"context": "prgs",
"role": "reviewer",
"username": "sysadmin",
"execution_profile": "prgs-reviewer",
"audit_label": "prgs-reviewer",
# no base_url on purpose: must fall back to context gitea
"auth": {"type": "keychain", "id": "prgs-gitea-reviewer-token"},
"allowed_operations": [
"read", "review", "comment", "approve",
"request_changes", "merge",
],
"forbidden_operations": [
"branch", "commit", "push", "open_pr",
],
},
"retired-author": {
"enabled": False,
"context": "prgs",
"role": "author",
"username": "jcwalker3",
"base_url": "https://gitea.prgs.cc",
"auth": {"type": "keychain", "id": "retired-token-ref"},
"allowed_operations": ["read"],
"forbidden_operations": [],
},
"lab-author": {
"enabled": True,
"context": "lab",
"role": "author",
"username": "jcwalker3",
"base_url": "http://localhost:3000",
"auth": {"type": "keychain", "id": "lab-gitea-author-token"},
"allowed_operations": ["read"],
"forbidden_operations": [],
},
},
"projects": {
"/repo/one": {
"enabled": True,
"context": "prgs",
"default_owner": "Scaled-Tech-Consulting",
"default_repo": "One",
"default_author_profile": "prgs-author",
"default_reviewer_profile": "prgs-reviewer",
},
"/repo/lab": {
"enabled": False,
"context": "lab",
},
},
"rules": {
"disabled_behavior": "report in audits, never act",
"no_silent_fallback": True,
"tokens_in_json": False,
"token_storage": "keychain",
"hide_service_urls_from_llm": True,
"hide_keychain_ids_from_llm": True,
"mcp_resolves_endpoints": True,
},
}
def write_config(data):
"""Write *data* to a temp JSON file and return its path."""
fd, path = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(data, fh)
return path
def load(data):
"""Load *data* through gitea_config via a temp file, then clean up."""
path = write_config(data)
try:
return gitea_config.load_config(path)
finally:
os.unlink(path)
class LoadContextsShapeTests(unittest.TestCase):
def test_contexts_shape_loads(self):
config = load(contexts_config())
self.assertEqual(config["version"], 2)
self.assertIn("prgs-author", config["profiles"])
self.assertIn("prgs-reviewer", config["profiles"])
def test_active_profile_resolved_from_env(self):
path = write_config(contexts_config())
try:
with patch.dict(os.environ, {
gitea_config.ENV_CONFIG_PATH: path,
gitea_config.ENV_PROFILE: "prgs-author",
}):
profile = gitea_config.resolve_profile()
finally:
os.unlink(path)
self.assertEqual(profile["username"], "jcwalker3")
self.assertEqual(profile["base_url"], "https://gitea.prgs.cc")
self.assertEqual(profile["context"], "prgs")
def test_base_url_falls_back_to_context_gitea(self):
profile = gitea_config.select_profile(load(contexts_config()),
"prgs-reviewer")
self.assertEqual(profile["base_url"], "https://gitea.prgs.cc")
def test_profile_without_any_base_url_is_refused(self):
data = contexts_config()
del data["profiles"]["prgs-author"]["base_url"]
data["contexts"]["prgs"]["gitea"]["enabled"] = False
config = load(data)
with self.assertRaises(gitea_config.ConfigError):
gitea_config.select_profile(config, "prgs-author")
def test_v1_config_still_loads(self):
config = load({
"version": 1,
"profiles": {
"prgs": {
"base_url": "https://gitea.prgs.cc",
"auth": {"type": "keychain", "id": "prgs-gitea-token"},
},
},
})
profile = gitea_config.select_profile(config, "prgs")
self.assertEqual(profile["base_url"], "https://gitea.prgs.cc")
def test_mixed_contexts_and_environments_rejected(self):
data = contexts_config()
data["environments"] = {"x": {"services": {}}}
with self.assertRaises(gitea_config.ConfigError):
load(data)
def test_missing_enabled_flag_is_refused(self):
data = contexts_config()
del data["profiles"]["prgs-author"]["enabled"]
with self.assertRaises(gitea_config.ConfigError) as ctx:
load(data)
self.assertIn("enabled", str(ctx.exception))
class DisabledRefusalTests(unittest.TestCase):
def setUp(self):
self.config = load(contexts_config())
def test_disabled_profile_refused(self):
with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.select_profile(self.config, "retired-author")
self.assertIn("disabled", str(ctx.exception))
def test_profile_in_disabled_context_refused(self):
with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.select_profile(self.config, "lab-author")
self.assertIn("disabled", str(ctx.exception))
def test_enabled_profile_still_selectable(self):
profile = gitea_config.select_profile(self.config, "prgs-author")
self.assertEqual(profile["context"], "prgs")
def test_disabled_service_refused(self):
with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.resolve_service(self.config, "prgs", "sentry")
self.assertIn("disabled", str(ctx.exception))
def test_enabled_service_resolves_internally_with_auth_reference(self):
# Internal resolution keeps the URL + auth reference for MCP's own use;
# they must never appear in LLM-facing (audit/summary) output.
service = gitea_config.resolve_service(self.config, "prgs", "jenkins")
self.assertEqual(service["base_url"], "https://jenkins.prgs.cc")
self.assertEqual(service["auth"], {"type": "keychain",
"id": "prgs-jenkins-token"})
self.assertNotIn("token", service)
def test_service_in_disabled_context_refused(self):
with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.resolve_service(self.config, "lab", "jenkins")
self.assertIn("disabled", str(ctx.exception))
def test_unknown_service_fails_closed(self):
with self.assertRaises(gitea_config.ConfigError):
gitea_config.resolve_service(self.config, "prgs", "nope")
class ProjectMappingTests(unittest.TestCase):
def setUp(self):
self.config = load(contexts_config())
def test_project_maps_to_context(self):
project = gitea_config.project_for_path(self.config, "/repo/one")
self.assertEqual(project["context"], "prgs")
self.assertEqual(project["default_reviewer_profile"], "prgs-reviewer")
def test_unknown_project_returns_none(self):
self.assertIsNone(
gitea_config.project_for_path(self.config, "/repo/unknown"))
def test_disabled_project_refused(self):
with self.assertRaises(gitea_config.ConfigError) as ctx:
gitea_config.project_for_path(self.config, "/repo/lab")
self.assertIn("disabled", str(ctx.exception))
class SecretHandlingTests(unittest.TestCase):
def test_inline_profile_token_rejected(self):
data = contexts_config()
data["profiles"]["prgs-author"]["token"] = FAKE_TOKEN
with self.assertRaises(gitea_config.ConfigError) as ctx:
load(data)
self.assertNotIn(FAKE_TOKEN, str(ctx.exception))
def test_inline_service_token_rejected(self):
data = contexts_config()
data["contexts"]["prgs"]["services"]["jenkins"]["token"] = FAKE_TOKEN
with self.assertRaises(gitea_config.ConfigError) as ctx:
load(data)
self.assertNotIn(FAKE_TOKEN, str(ctx.exception))
def test_selected_profile_resolves_token_via_keychain(self):
profile = gitea_config.select_profile(load(contexts_config()),
"prgs-author")
token = gitea_config.resolve_token(
profile, keychain_lookup=lambda item_id: FAKE_TOKEN
if item_id == "prgs-gitea-author-token" else None)
self.assertEqual(token, FAKE_TOKEN)
class AuditTests(unittest.TestCase):
"""LLM-facing audit output: enabled/disabled state only — no endpoint
URLs, no keychain ids, no token values. Admin opt-in reveals endpoints
and auth source names (never token values)."""
def setUp(self):
self.config = load(contexts_config())
def test_audit_reports_enabled_and_disabled(self):
report = gitea_config.audit_config(self.config)
profiles = {p["name"]: p for p in report["profiles"]}
self.assertTrue(profiles["prgs-author"]["enabled"])
self.assertFalse(profiles["retired-author"]["enabled"])
services = {(s["context"], s["name"]): s for s in report["services"]}
self.assertTrue(services[("prgs", "jenkins")]["enabled"])
self.assertFalse(services[("prgs", "sentry")]["enabled"])
self.assertFalse(services[("lab", "jenkins")]["enabled"])
def test_audit_hides_urls_keychain_ids_and_tokens_by_default(self):
rendered = json.dumps(gitea_config.audit_config(self.config))
for leaked in ("https://", "http://", "prgs-gitea-author-token",
"prgs-jenkins-token", "base_url", FAKE_TOKEN):
self.assertNotIn(leaked, rendered)
# Auth is reported as a status, not a reference.
report = gitea_config.audit_config(self.config)
profiles = {p["name"]: p for p in report["profiles"]}
self.assertEqual(profiles["prgs-author"]["auth"], "keychain")
def test_audit_admin_optin_reveals_endpoints_but_never_tokens(self):
report = gitea_config.audit_config(self.config, reveal_endpoints=True)
rendered = json.dumps(report)
self.assertIn("https://jenkins.prgs.cc", rendered)
self.assertIn("keychain:prgs-gitea-author-token", rendered)
self.assertNotIn(FAKE_TOKEN, rendered)
def test_audit_works_for_v1_config(self):
report = gitea_config.audit_config({
"version": 1,
"profiles": {
"prgs": {
"base_url": "https://gitea.prgs.cc",
"auth": {"type": "keychain", "id": "prgs-gitea-token"},
},
},
})
profiles = {p["name"]: p for p in report["profiles"]}
self.assertTrue(profiles["prgs"]["enabled"])
self.assertEqual(profiles["prgs"]["auth"], "keychain")
self.assertNotIn("https://", json.dumps(report))
class ServiceSummaryTests(unittest.TestCase):
"""Safe one-line summaries for LLM sessions: label + state only."""
def setUp(self):
self.config = load(contexts_config())
def test_summaries_show_state_without_urls_or_ids(self):
lines = gitea_config.service_summaries(
self.config, auth_check=lambda service: True)
text = "\n".join(lines)
self.assertIn("PRGS Jenkins: enabled, read-only, authenticated", text)
self.assertIn("PRGS Sentry: disabled", text)
self.assertIn("Lab Jenkins: disabled", text)
for leaked in ("https://", "http://", "keychain",
"prgs-jenkins-token"):
self.assertNotIn(leaked, text)
def test_summary_reports_missing_auth_without_secrets(self):
lines = gitea_config.service_summaries(
self.config, auth_check=lambda service: False)
text = "\n".join(lines)
self.assertIn("PRGS Jenkins: enabled, read-only, no credential", text)
class NoSilentFallbackTests(unittest.TestCase):
def test_broken_config_fails_auth_instead_of_falling_back(self):
"""With GITEA_MCP_CONFIG set but unloadable, auth must fail closed."""
path = write_config({"version": 2}) # invalid: no contexts/environments
env = {
gitea_config.ENV_CONFIG_PATH: path,
gitea_config.ENV_PROFILE: "prgs-author",
}
try:
with patch.dict(os.environ, env, clear=False), \
patch.object(gitea_auth, "get_credentials",
return_value=(None, None)):
for var in ("GITEA_TOKEN", "GITEA_TOKEN_PRGS",
"GITEA_TOKEN_DADESCHOOLS"):
os.environ.pop(var, None)
with self.assertRaises(gitea_config.ConfigError):
gitea_auth.get_auth_header("https://gitea.prgs.cc")
finally:
os.unlink(path)
def test_env_only_users_unaffected(self):
"""Without GITEA_MCP_CONFIG, a missing token still degrades quietly."""
env = dict(os.environ)
env.pop(gitea_config.ENV_CONFIG_PATH, None)
with patch.dict(os.environ, env, clear=True), \
patch.object(gitea_auth, "get_credentials",
return_value=(None, None)):
for var in ("GITEA_TOKEN", "GITEA_TOKEN_PRGS",
"GITEA_TOKEN_DADESCHOOLS"):
os.environ.pop(var, None)
self.assertIsNone(
gitea_auth.get_auth_header("https://gitea.prgs.cc"))
class ValidateConfigTests(unittest.TestCase):
def test_valid_contexts_config_has_no_problems(self):
self.assertEqual(gitea_config.validate_config(contexts_config()), [])
def test_repo_example_file_validates(self):
example = __import__("pathlib").Path(__file__).resolve().parent.parent \
/ "gitea-mcp.v2-contexts.example.json"
with open(example, encoding="utf-8") as fh:
self.assertEqual(gitea_config.validate_config(json.load(fh)), [])
def test_broken_contexts_config_reports_problems(self):
data = contexts_config()
data["profiles"]["prgs-author"]["context"] = "nope"
problems = gitea_config.validate_config(data)
self.assertTrue(problems)
if __name__ == "__main__":
unittest.main()
+145 -3
View File
@@ -3,6 +3,7 @@
Each tool is tested by calling the underlying function directly (not through
the MCP protocol) with mocked API responses.
"""
import json
import os
import sys
import unittest
@@ -880,7 +881,9 @@ class TestWhoami(unittest.TestCase):
self.assertEqual(result["username"], "reviewer-bot")
self.assertEqual(result["display_name"], "Reviewer Bot")
self.assertEqual(result["user_id"], 42)
self.assertEqual(result["server"], "https://gitea.prgs.cc")
# Endpoint URLs are hidden from normal LLM-facing output (#120);
# the logical remote name is the addressing surface.
self.assertNotIn("server", result)
self.assertEqual(result["remote"], "prgs")
# Read-only: GET against the authenticated-user endpoint.
call_args = mock_api.call_args
@@ -1035,8 +1038,12 @@ class TestProfileDiscovery(unittest.TestCase):
self.assertEqual(result["allowed_operations"], ["read", "review", "approve"])
self.assertEqual(result["authenticated_username"], "reviewer-bot")
self.assertEqual(result["identity_status"], "verified")
self.assertEqual(result["server"], "https://gitea.prgs.cc")
self.assertEqual(result["token_source_name"], "GITEA_TOKEN")
# Endpoint URLs and token source names are hidden from normal
# LLM-facing output (#120); auth is reported as a status only.
self.assertNotIn("server", result)
self.assertNotIn("base_url", result)
self.assertNotIn("token_source_name", result)
self.assertEqual(result["auth_status"], "configured")
# Read-only: only a GET to the user endpoint was issued.
self.assertEqual(mock_api.call_args[0][0], "GET")
self.assertTrue(mock_api.call_args[0][1].endswith("/api/v1/user"))
@@ -1669,3 +1676,138 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
# branch name fallback
self.assertEqual(extract_linked_issue_numbers("", branch_name="issue-123"), [123])
self.assertEqual(extract_linked_issue_numbers("", branch_name="feat/issue-123-foo"), [123])
# ---------------------------------------------------------------------------
# Endpoint/keychain redaction in LLM-facing output — issue #120
# ---------------------------------------------------------------------------
class TestEndpointRedaction(unittest.TestCase):
"""Normal MCP output hides endpoint URLs and keychain ids; the admin
opt-in (GITEA_MCP_REVEAL_ENDPOINTS) restores them for local diagnostics
without ever revealing token values."""
def _contexts_config_file(self):
import tempfile
config = {
"version": 2,
"contexts": {
"prgs": {
"enabled": True,
"gitea": {"enabled": True, "kind": "gitea",
"base_url": "https://gitea.prgs.cc"},
"services": {
"jenkins": {
"enabled": True, "kind": "jenkins",
"label": "PRGS Jenkins",
"base_url": "https://jenkins.prgs.cc",
"auth": {"type": "keychain",
"id": "prgs-jenkins-token"},
"capabilities": ["read"],
},
"sentry": {
"enabled": False, "kind": "sentry",
"label": "PRGS Sentry", "base_url": "",
"auth": {"type": "keychain",
"id": "prgs-sentry-token"},
"capabilities": ["read"],
},
},
},
},
"profiles": {
"prgs-author": {
"enabled": True, "context": "prgs", "role": "author",
"username": "jcwalker3",
"base_url": "https://gitea.prgs.cc",
"auth": {"type": "keychain",
"id": "prgs-gitea-author-token"},
"allowed_operations": ["read"],
"forbidden_operations": [],
},
},
"projects": {},
"rules": {"hide_service_urls_from_llm": True,
"hide_keychain_ids_from_llm": True,
"mcp_resolves_endpoints": True},
}
fd, path = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(config, fh)
return path
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_whoami_hides_endpoint_url_by_default(self, _auth, mock_api):
mock_api.return_value = {"id": 1, "login": "someone"}
with patch.dict(os.environ, {}, clear=True):
result = gitea_whoami(remote="prgs")
self.assertNotIn("server", result)
self.assertNotIn("https://", repr(result))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_whoami_reveals_endpoint_with_admin_optin(self, _auth, mock_api):
mock_api.return_value = {"id": 1, "login": "someone"}
with patch.dict(os.environ,
{"GITEA_MCP_REVEAL_ENDPOINTS": "1"}, clear=True):
result = gitea_whoami(remote="prgs")
self.assertEqual(result["server"], "https://gitea.prgs.cc")
def test_get_profile_hides_url_and_token_source_by_default(self):
env = {
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_BASE_URL": "https://gitea.example.invalid",
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
}
with patch.dict(os.environ, env, clear=True):
result = gitea_get_profile(remote="prgs",
resolve_identity=False)
blob = repr(result)
for leaked in ("https://", "keychain:", "some-item-id",
"base_url", "server", "token_source_name"):
self.assertNotIn(leaked, blob)
self.assertEqual(result["auth_status"], "configured")
def test_get_profile_reports_unconfigured_auth(self):
with patch.dict(os.environ,
{"GITEA_PROFILE_NAME": "gitea-author"}, clear=True):
result = gitea_get_profile(remote="prgs",
resolve_identity=False)
self.assertEqual(result["auth_status"], "unconfigured")
def test_get_profile_reveals_with_admin_optin(self):
env = {
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
"GITEA_MCP_REVEAL_ENDPOINTS": "1",
}
with patch.dict(os.environ, env, clear=True):
result = gitea_get_profile(remote="prgs",
resolve_identity=False)
self.assertEqual(result["server"], "https://gitea.prgs.cc")
self.assertEqual(result["token_source_name"], "keychain:some-item-id")
def test_audit_tool_reports_state_without_urls_or_ids(self):
from mcp_server import gitea_audit_config
path = self._contexts_config_file()
try:
env = {"GITEA_MCP_CONFIG": path,
"GITEA_MCP_PROFILE": "prgs-author"}
with patch.dict(os.environ, env, clear=True), \
patch("gitea_config._keychain_token", return_value="x"):
result = gitea_audit_config()
finally:
os.unlink(path)
blob = json.dumps(result)
self.assertIn("PRGS Jenkins: enabled, read-only, authenticated",
result["summaries"])
self.assertIn("PRGS Sentry: disabled", result["summaries"])
for leaked in ("https://", "http://", "prgs-jenkins-token",
"prgs-gitea-author-token", "base_url"):
self.assertNotIn(leaked, blob)
def test_audit_tool_without_config_reports_off(self):
with patch.dict(os.environ, {}, clear=True):
from mcp_server import gitea_audit_config
result = gitea_audit_config()
self.assertFalse(result["configured"])