diff --git a/.env.example b/.env.example index 093e238..52d9bc9 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,14 @@ GITEA_PROFILE_NAME=gitea-reviewer # Optional, comma-separated operation categories this profile is intended for # (descriptive only in this issue; enforcement is a later roadmap item). GITEA_ALLOWED_OPERATIONS=read,review,approve + +# Optional, comma-separated operation categories this profile must NOT perform +# (descriptive metadata; surfaced by gitea_get_profile). +GITEA_FORBIDDEN_OPERATIONS=merge,branch.push + +# Optional short label attached to this runtime for audit purposes. +GITEA_AUDIT_LABEL=reviewer-runtime + +# Optional NAME of the token's source (e.g. an env var name). This is a name +# only — never the token value. Surfaced by gitea_get_profile. +GITEA_TOKEN_SOURCE=GITEA_TOKEN diff --git a/README.md b/README.md index 90e4b7a..b32f68c 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Any MCP-compatible agent (Antigravity, Claude Code, etc.) can call these tools n | `gitea_list_issues` | List issues with state/label filters | | `gitea_view_issue` | Get full details of a single issue | | `gitea_whoami` | Read-only: identify the authenticated Gitea account (safe metadata only) | +| `gitea_get_profile` | Read-only: describe the active runtime execution profile (safe metadata only) | | `gitea_mark_issue` | Claim/release an issue (start/done) | | `gitea_list_labels` | List all available labels in a repository | | `gitea_create_label` | Create a new label with custom color | @@ -161,16 +162,20 @@ Recognized environment fields (see [`.env.example`](.env.example) for placeholde | `GITEA_TOKEN` | API token for this runtime. Read only by the auth layer; **never** returned, logged, or committed. | | `GITEA_PROFILE_NAME` | Non-secret label for the running profile (e.g. `gitea-reviewer`). Surfaced by `gitea_whoami`. | | `GITEA_ALLOWED_OPERATIONS` | Optional, comma-separated operation categories (descriptive metadata only for now). | +| `GITEA_FORBIDDEN_OPERATIONS` | Optional, comma-separated categories this profile must not perform (descriptive). | +| `GITEA_AUDIT_LABEL` | Optional short label for this runtime, for audit purposes. | +| `GITEA_TOKEN_SOURCE` | Optional *name* of the token source (e.g. an env var name). A name only — never the token value. | | `GITEA_BASE_URL` | Optional informational base URL. | Notes: - This provides **one token + one profile per process**. It does not implement multi-token switching inside a single runtime, nor any approve/merge/eligibility - gating — those are later roadmap items (#13–#18). + gating — those are later roadmap items (#14–#18). - Profile name and allowed operations are **metadata only**; the token value is - never part of any tool output. `gitea_whoami` returns the profile name so a - workflow can see which runtime it is talking to. + never part of any tool output. `gitea_whoami` returns the profile name, and + `gitea_get_profile` returns the full non-secret profile metadata so a workflow + can inspect which runtime it is talking to before deciding to act. - See [`docs/gitea-execution-profiles.md`](docs/gitea-execution-profiles.md) for the full profile model. diff --git a/gitea_auth.py b/gitea_auth.py index 2890549..e8cf183 100644 --- a/gitea_auth.py +++ b/gitea_auth.py @@ -181,24 +181,39 @@ def get_profile(): environment variables. This function reads only the non-secret profile metadata: - - ``GITEA_PROFILE_NAME`` — a human label for the running profile. - - ``GITEA_ALLOWED_OPERATIONS`` — optional comma-separated operation + - ``GITEA_PROFILE_NAME`` — a human label for the running profile. + - ``GITEA_ALLOWED_OPERATIONS`` — optional comma-separated operation categories (descriptive only; not enforced here). - - ``GITEA_BASE_URL`` — optional informational base URL. + - ``GITEA_FORBIDDEN_OPERATIONS`` — optional comma-separated operation + categories this profile must not perform (descriptive only). + - ``GITEA_AUDIT_LABEL`` — optional short label for audit records. + - ``GITEA_TOKEN_SOURCE`` — optional *name* of the secret source + (e.g. an env var name). This is a name only, never a token value. + - ``GITEA_BASE_URL`` — optional informational base URL. It never reads, returns, or logs ``GITEA_TOKEN`` or any credential. The token continues to be resolved separately by ``get_auth_header`` and is never part of this metadata. Callers may surface the result safely. Returns: - dict with 'profile_name', 'allowed_operations' (list), and 'base_url'. + dict with 'profile_name', 'allowed_operations' (list), + 'forbidden_operations' (list), 'audit_label', 'token_source_name', + and 'base_url'. """ name = (os.environ.get("GITEA_PROFILE_NAME") or "gitea-default").strip() raw_ops = os.environ.get("GITEA_ALLOWED_OPERATIONS") or "" ops = [o.strip() for o in raw_ops.split(",") if o.strip()] + raw_forbidden = os.environ.get("GITEA_FORBIDDEN_OPERATIONS") or "" + forbidden = [o.strip() for o in raw_forbidden.split(",") if o.strip()] + audit_label = (os.environ.get("GITEA_AUDIT_LABEL") or "").strip() or None + # A *name* of the token source (e.g. "GITEA_TOKEN"), never the token value. + token_source = (os.environ.get("GITEA_TOKEN_SOURCE") or "").strip() or None base_url = os.environ.get("GITEA_BASE_URL") or None return { "profile_name": name, "allowed_operations": ops, + "forbidden_operations": forbidden, + "audit_label": audit_label, + "token_source_name": token_source, "base_url": base_url, } diff --git a/mcp_server.py b/mcp_server.py index 06274e5..44b736a 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -651,6 +651,76 @@ def gitea_whoami( } +@mcp.tool() +def gitea_get_profile( + remote: str = "dadeschools", + host: str | None = None, + resolve_identity: bool = True, +) -> dict: + """Describe the active Gitea MCP execution profile for this runtime. + + 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. + + This tool never mutates Gitea and never approves, merges, comments, or + creates anything. It never returns the token value, Authorization header, + password, raw environment, or credential file paths. Identity resolution + fails soft: if it cannot be determined, ``authenticated_username`` is null + and ``identity_status`` marks it, but the profile config is still returned. + + Args: + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + resolve_identity: If True, attempt a read-only identity lookup. + + Returns: + dict of safe profile metadata. ``identity_status`` is one of + 'verified', 'unknown', 'unavailable', or 'not_resolved'. + """ + profile = get_profile() + 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"], + "remote": remote if remote in REMOTES else None, + "server": None, + "authenticated_username": None, + "identity_status": "not_resolved", + } + + if remote not in REMOTES: + # Mark ambiguity rather than raising: the tool stays inspectable. + result["identity_status"] = "unknown" + result["remote_error"] = f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}" + return result + + h = host or REMOTES[remote]["host"] + result["server"] = f"https://{h}" + + if resolve_identity: + try: + auth = _auth(h) + data = api_request("GET", f"https://{h}/api/v1/user", auth) + login = (data or {}).get("login") + if login: + result["authenticated_username"] = login + result["identity_status"] = "verified" + else: + result["identity_status"] = "unknown" + except Exception: + # Fail soft for the identity field only. Never surface the error + # detail or any credential material — just mark it unavailable. + result["identity_status"] = "unavailable" + + return result + + @mcp.tool() def gitea_mark_issue( issue_number: int, diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index cd62680..86ead57 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -27,6 +27,7 @@ from mcp_server import ( # noqa: E402 gitea_get_file, gitea_commit_files, gitea_whoami, + gitea_get_profile, ) from gitea_auth import get_profile # noqa: E402 @@ -547,8 +548,10 @@ class TestRuntimeProfile(unittest.TestCase): with patch.dict(os.environ, env, clear=True): p = get_profile() blob = repr(p).lower() + # The token VALUE must never appear. (The field name + # 'token_source_name' is non-secret metadata and may exist.) self.assertNotIn("super-secret-token", blob) - self.assertNotIn("token", blob) + self.assertIsNone(p.get("token_source_name")) # GITEA_TOKEN_SOURCE unset @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @@ -570,5 +573,88 @@ class TestRuntimeProfile(unittest.TestCase): self.assertNotIn(secret, blob) +# --------------------------------------------------------------------------- +# Profile discovery (read-only) — issue #13 +# --------------------------------------------------------------------------- +class TestProfileDiscovery(unittest.TestCase): + + def test_get_profile_new_fields_default_empty(self): + with patch.dict(os.environ, {}, clear=True): + p = get_profile() + self.assertEqual(p["forbidden_operations"], []) + self.assertIsNone(p["audit_label"]) + self.assertIsNone(p["token_source_name"]) + + def test_get_profile_reads_all_metadata(self): + env = { + "GITEA_PROFILE_NAME": "gitea-reviewer", + "GITEA_ALLOWED_OPERATIONS": "read,review,approve", + "GITEA_FORBIDDEN_OPERATIONS": "merge, branch.push", + "GITEA_AUDIT_LABEL": "reviewer-runtime", + "GITEA_TOKEN_SOURCE": "GITEA_TOKEN", + } + with patch.dict(os.environ, env, clear=True): + p = get_profile() + self.assertEqual(p["forbidden_operations"], ["merge", "branch.push"]) + self.assertEqual(p["audit_label"], "reviewer-runtime") + self.assertEqual(p["token_source_name"], "GITEA_TOKEN") + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_discovery_verified_identity(self, _auth, mock_api): + mock_api.return_value = {"id": 3, "login": "reviewer-bot"} + env = { + "GITEA_PROFILE_NAME": "gitea-reviewer", + "GITEA_ALLOWED_OPERATIONS": "read,review,approve", + "GITEA_TOKEN_SOURCE": "GITEA_TOKEN", + "GITEA_TOKEN": "super-secret-token", + } + with patch.dict(os.environ, env, clear=True): + result = gitea_get_profile(remote="prgs") + self.assertEqual(result["profile_name"], "gitea-reviewer") + 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") + # 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")) + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_discovery_never_exposes_secrets(self, _auth, mock_api): + mock_api.return_value = {"id": 3, "login": "reviewer-bot"} + env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_TOKEN": "super-secret-token"} + with patch.dict(os.environ, env, clear=True): + result = gitea_get_profile(remote="prgs") + blob = repr(result).lower() + for secret in ("super-secret-token", "token dgvzd", "authorization", "basic ", FAKE_AUTH.lower()): + self.assertNotIn(secret, blob) + + @patch("mcp_server.get_auth_header", return_value=None) + def test_discovery_identity_unavailable_fails_soft(self, _auth): + # No credentials -> _auth raises inside the tool; identity marked + # unavailable but the profile config is still returned (not raised). + with patch.dict(os.environ, {"GITEA_PROFILE_NAME": "gitea-author"}, clear=True): + result = gitea_get_profile(remote="prgs") + self.assertEqual(result["profile_name"], "gitea-author") + self.assertIsNone(result["authenticated_username"]) + self.assertEqual(result["identity_status"], "unavailable") + + def test_discovery_can_skip_identity(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["identity_status"], "not_resolved") + self.assertIsNone(result["authenticated_username"]) + + def test_discovery_unknown_remote_marks_unknown(self): + with patch.dict(os.environ, {}, clear=True): + result = gitea_get_profile(remote="nope") + self.assertEqual(result["identity_status"], "unknown") + self.assertIsNone(result["remote"]) + self.assertIn("remote_error", result) + + if __name__ == "__main__": unittest.main()