From ff920a6496da0cb8fb82e50e11fc1808c67058bd Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Fri, 3 Jul 2026 02:19:39 -0400 Subject: [PATCH] feat: load profiles.json v2 contexts shape with enabled enforcement and LLM-safe output (#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + gitea-mcp.v2-contexts.example.json | 80 ++++++ gitea_auth.py | 10 +- gitea_config.py | 384 ++++++++++++++++++++++++- mcp_server.py | 72 ++++- tests/test_config.py | 8 +- tests/test_config_v2_contexts.py | 446 +++++++++++++++++++++++++++++ tests/test_mcp_server.py | 148 +++++++++- 8 files changed, 1127 insertions(+), 22 deletions(-) create mode 100644 gitea-mcp.v2-contexts.example.json create mode 100644 tests/test_config_v2_contexts.py diff --git a/.gitignore b/.gitignore index acc0c95..e6fe104 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/gitea-mcp.v2-contexts.example.json b/gitea-mcp.v2-contexts.example.json new file mode 100644 index 0000000..aace879 --- /dev/null +++ b/gitea-mcp.v2-contexts.example.json @@ -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 + } +} diff --git a/gitea_auth.py b/gitea_auth.py index a429b7b..ac754e4 100644 --- a/gitea_auth.py +++ b/gitea_auth.py @@ -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: diff --git a/gitea_config.py b/gitea_config.py index 82e9aa7..f36b835 100644 --- a/gitea_config.py +++ b/gitea_config.py @@ -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:`` / 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, "") + _load_v2_any(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) diff --git a/mcp_server.py b/mcp_server.py index a26d510..3d6d26b 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -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, diff --git a/tests/test_config.py b/tests/test_config.py index fa02082..e5f05c5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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") # --------------------------------------------------------------------------- diff --git a/tests/test_config_v2_contexts.py b/tests/test_config_v2_contexts.py new file mode 100644 index 0000000..71d3a7b --- /dev/null +++ b/tests/test_config_v2_contexts.py @@ -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() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index dfe5be6..5ac086a 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -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"]) -- 2.43.7