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>
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user