Files
AegisGitea-MCP/docs/audit.md
T
Latte 8b7bb7205c docs: phase 0 audit, chunk plan, ADRs and pinned Gitea 28.1.0 swagger
Inventory and gap analysis of the 0.2.0 codebase against the full Gitea API
and the official gitea-mcp toolset, the B1..B11 + security chunk plan, ADRs
0001-0006 for the full-access rebuild, SDK/OAuth research notes, and the
instance swagger pinned for tool generation. The audit-logging doc moves to
docs/audit-logging.md.
2026-10-14 15:34:48 +02:00

245 lines
19 KiB
Markdown

# Codebase audit and chunk plan
Date: 2026-10-14. Baseline: `dev` at `2b99644` (package version 0.2.0).
Target: a complete, full-access Gitea MCP server covering the entire instance
API (Gitea 28.1.0 swagger, pinned at `spec/gitea-swagger.v1.json`), served over
stdio and streamable HTTP, with admin-token and per-user OAuth modes.
Supporting research (SDK signatures, Gitea OAuth facts, client requirements,
all with source URLs): [research/sdk-oauth-gitea.md](research/sdk-oauth-gitea.md).
Design decisions: [adr/](adr/).
## 1. Inventory of the current codebase
### 1.1 MCP tools (44)
28 read tools, 15 write tools, 1 hybrid escape hatch. Definitions live in
`mcp_protocol.py` (`AVAILABLE_TOOLS`), handlers in `registry.py` and `tools/*`,
HTTP calls in `gitea_client.py`.
| Group | Tools | Gitea endpoints |
|---|---|---|
| Repository read | `list_repositories`, `get_repository_info`, `get_file_tree`, `get_file_contents`, `search_code`, `list_commits`, `get_commit_diff`, `compare_refs`, `list_branches`, `get_branch`, `list_tags`, `get_repo_languages`, `list_repo_topics`, `get_commit_status` | `/user/repos`, `/repos/search`, `R`, `R/git/trees`, `R/contents`, `R/search` (does not exist in the swagger), `R/commits`, `R/git/commits`, `R/compare`, `R/branches`, `R/tags`, `R/languages`, `R/topics`, `R/commits/{sha}/status` |
| Issues / PRs read | `list_issues`, `get_issue`, `list_issue_comments`, `list_labels`, `list_milestones`, `list_pull_requests`, `get_pull_request`, `list_pull_request_files`, `list_pull_request_commits` | `R/issues*`, `R/labels`, `R/milestones`, `R/pulls*` |
| Releases / orgs read | `list_releases`, `get_release`, `get_latest_release`, `list_organizations`, `list_org_repositories` | `R/releases*`, `/user/orgs`, `/orgs/{org}/repos` |
| Write | `create_issue`, `update_issue`, `create_issue_comment`, `create_pr_comment`, `edit_issue_comment`, `add_labels`, `remove_labels`, `assign_issue`, `create_label`, `update_label`, `create_milestone`, `create_pull_request`, `create_release`, `edit_release`, `create_branch` | `R/issues*`, `R/labels*`, `R/milestones`, `R/pulls`, `R/releases*`, `R/branches` |
| Escape hatch | `gitea_request` | any known prefix, admin/credential segments denied |
(`R` = `/repos/{owner}/{repo}`.)
### 1.2 Transports
- stdio (`stdio_app.py`) is the only part using the `mcp` SDK, via the v1
low-level decorators (`@server.list_tools()`). The dependency is unbounded
(`mcp>=1.2.0`), resolves to `mcp==2.3.0`, and under 2.x `build_server()`
raises `AttributeError`. The stdio adapter is broken today.
- HTTP (`server.py`, FastAPI) is hand-rolled: a minimal JSON-RPC dispatcher on
`POST /mcp` with a fixed `protocolVersion: "2024-11-05"`, no session id, no
streamed responses, errors as JSON-RPC errors rather than `isError` results.
`GET /mcp` is a heartbeat-only SSE stream. The README calls this "Streamable
HTTP"; it is not a conforming implementation.
- Extra routes: `/`, `/health`, `/metrics` (unauthenticated), OAuth metadata and
proxy routes, `/register` (public, unthrottled, writes to disk on every call),
`/automation/*`, `/mcp/tools` (public), `/mcp/tool/call`, plus FastAPI's
`/docs`, `/redoc`, `/openapi.json` (public).
### 1.3 Configuration
`config.py` (pydantic-settings) has about 40 variables. Security defaults:
`WRITE_MODE=false`, `RAW_API_ALLOW_SENSITIVE=false`, `MCP_HOST=127.0.0.1`
(only the literal `0.0.0.0` is blocked), `EXPOSE_ERROR_DETAILS=false`,
`SECRET_DETECTION_MODE=mask`. `AUTH_ENABLED`, `MCP_API_KEYS` and `OAUTH_MODE`
only affect config validation; runtime auth is always Gitea-OAuth validation.
`AUTOMATION_SCHEDULER_ENABLED` is never read.
### 1.4 Authentication
- `auth.py` (static API keys) is dead at runtime: `MCP_API_KEYS` is never
compared against anything. The key-management scripts manage keys the server
ignores.
- `oauth.py` validates bearer tokens as Gitea OAuth tokens (JWT via JWKS, else
`/login/oauth/userinfo`).
- `oauth_flow.py` proxies the authorization code flow (PKCE S256, HMAC-signed
state) and runs a small DCR registry on disk. It does not check the
`redirect_uri` against the registered client's own redirect URIs.
- Every call runs on a single service PAT (`GITEA_TOKEN`); per-user permissions
are emulated with a collaborator-permission probe (`server.py`) and a
resource-type gate (`authz.py`). The user's own Gitea token is never used for
API calls.
### 1.5 Supporting modules
| Module | Role | Fate under full access |
|---|---|---|
| `audit.py` | Hash-chained JSONL audit log | Keep. About 38 client methods log only a "pending" entry; the new dispatcher logs one complete event per call. |
| `security.py` | Regex secret detection, mask/block | Keep as output safety net. |
| `response_limits.py` | Item and character caps | Keep. |
| `request_context.py`, `errors.py`, `logging_utils.py`, `cache.py` | Context vars, `ToolError`, JSON logging, TTL cache | Keep or fold into the new core. |
| `policy.py` | YAML allow/deny plus the write gate | Retire (ADR 0006). Replaced by `READ_ONLY`, `TOOLSETS`, `DISABLED_TOOLS`. |
| `authz.py` | Fail-closed emulation of per-user rights on a service PAT | Retire. Per-user mode now uses the user's own Gitea token, so Gitea enforces permissions itself. |
| `auth.py`, `oauth.py`, `oauth_flow.py` | Dead API keys, Gitea token validation, hand-rolled OAuth proxy | Replace with the SDK auth stack (ADR 0004). |
| `server.py`, `server_entry.py`, `mcp_protocol.py`, `registry.py`, `tools/*` | FastAPI adapter, custom protocol, 44 hand-written tools | Replace with the generated catalog and the SDK server (ADR 0002, 0003). |
| `automation.py` | Webhook ingest, placeholder jobs (45% covered) | Remove; out of scope for an API bridge. |
| `observability.py`, `rate_limit.py` | Unauthenticated `/metrics`, in-memory rate limits | Rate limiting kept for HTTP; `/metrics` removed (not in scope). |
### 1.6 Tests
351 tests in 21 files, Gitea always mocked with hand-made stubs (`pytest-httpx`
is installed but unused). Full run on this machine: 336 passed, 15 failed,
coverage 80.10%. Failures: 12 environment-dependent (default paths under
`/var/log` and `/var/lib`, one test module shadows the `reset_globals`
fixture), 3 real (`mcp` 2.x API break in the stdio tests). No live-Gitea
integration tests, no MCP transport tests over HTTP.
### 1.7 CI (`.gitea/workflows/`)
`lint.yml` (ruff, black, mypy), `test.yml` (pytest at 80%, `uv build` package
check), `docker.yml` (lint + test again, build, import smoke test, push to the
Gitea registry on main/dev), `publish.yml` (lint + test again, `uv publish` to
the Gitea PyPI registry on push to dev/main). Python 3.12 only, unpinned
`pip install -r requirements-dev.txt`. No secret scan, dependency audit,
security lint, integration tests or docs drift check. `docker.yml` runs
`docker image prune` and `docker builder prune` on a shared host.
### 1.8 Dependencies and packaging
`pyproject.toml` core: httpx, pydantic, pydantic-settings, PyYAML,
python-dotenv, structlog (never imported), mcp (unbounded). `[server]` extra:
fastapi, uvicorn, PyJWT, python-multipart. Dev: pytest stack, black, ruff,
mypy, pre-commit. No lockfile. The Dockerfile pip-installs `requirements.txt`
without installing the package itself and sets a stray `NODE_ENV`. The compose
file keeps `MCP_HOST=127.0.0.1` inside the container, so the published port is
unreachable unless the bind is widened.
### 1.9 Dead code and stale docs
- Code: `auth.py`, `AUTOMATION_SCHEDULER_ENABLED`, `observability.ToolTiming`,
`arguments.RAW_API_METHODS`, `raw_method_is_write`, most of
`tools/__init__.py` re-exports, `structlog`, `pytest-httpx`, the API-key
scripts. `search_code` targets a non-existent route. `list_repositories` is
capped at 50 results.
- Docs: `PLAN.md` phases 7 and 8 unchecked though done; `CLAUDE.md` says publish
runs on a `v*` tag (it runs on push to dev/main); API keys are documented as
working in four docs; `write-mode.md`, `roadmap.md` and `todo.md` claim merge
and content writes are impossible while `gitea_request` can do them; the
transport is described as streamable HTTP.
## 2. Gap analysis
### 2.1 Against the instance API (Gitea 28.1.0, 537 operations)
The existing tools cover 44 operations (about 8%). Every domain below is
missing entirely or partially. Counts use the domain split of ADR 0003.
| Domain | Operations | Covered today | Missing highlights |
|---|---|---|---|
| repo | 120 | ~17 | CRUD, contents writes, forks, mirrors, transfer, collaborators, topics writes, tags/refs writes, archives, LFS, git notes, templates, avatars, push mirrors, branch protection |
| issue | 90 | ~15 | comments delete, reactions, pins, subscriptions, time tracking, stopwatches, dependencies, attachments, label/milestone delete, issue templates, search |
| user | 73 | 0 | profile, emails, SSH/GPG keys, follow, blocks, tokens, OAuth apps, starred/watched, settings, searches |
| actions | 70 | 0 | workflow runs/jobs/logs/artifacts, dispatch, repo/org/user secrets and variables, runners and registration tokens |
| org | 36 | 2 | CRUD, members, public members, blocks, labels, avatars, activity |
| admin | 33 | 0 | users (create/edit/delete/rename, keys, orgs, repos, badges), orgs, emails, cron list/run, system hooks, unadopted repos, runners, packages |
| pull | 26 | 4 | update, merge, reviews (create/submit/dismiss/delete), review comments, requested reviewers, diff/patch, update branch |
| hook | 20 | 0 | repo, org, user and system webhooks |
| misc | 19 | 0 | version, settings, markdown/markup, licenses, gitignore, labels templates, nodeinfo, signing keys, activitypub |
| team | 15 | 0 | team CRUD, members, repos |
| release | 13 | 4 | delete, by tag, attachments |
| package | 9 | 0 | list, get, delete, files, link/unlink |
| notification | 7 | 0 | list, mark read, threads |
| wiki | 6 | 0 | pages CRUD, revisions |
Swagger operations that the API does not offer and therefore cannot get a tool:
queue management, instance configuration writes, and instance-wide OAuth2
applications (UI only). This is recorded honestly in the README rather than
faked with UI scraping.
### 2.2 Against the official `gitea/gitea-mcp` (v1.8.1, 59 tools)
gitea-mcp consolidates operations into `*_read`/`*_write` tools with a `method`
enum. Every one of its methods maps to a swagger operation, so one tool per
operation is a strict superset. gitea-mcp features we must match or exceed:
read-only mode, toolset and tool-name filters, `readOnlyHint`/`destructiveHint`
annotations, a health endpoint for HTTP. Features it lacks that we add: Sudo,
admin domain, hooks, packages beyond delete, users/orgs administration,
per-user OAuth with dynamic client registration (needed for Mistral Le Chat).
### 2.3 Platform gaps
- Conforming streamable HTTP transport (current one is hand-rolled).
- Working stdio on `mcp` 2.x.
- Static bearer auth for the MCP endpoint (the existing API keys are dead).
- Per-user OAuth where calls actually run under the user's Gitea identity.
- Setup command, live-Gitea integration tests, MCP-level smoke tests, docs
drift check, security scanning, gitleaks.
- `uv` lockfile, ruff format instead of black, coverage at 90%.
## 3. Chunk plan
Every chunk ends with: tests green, `ruff check`, `ruff format --check`, `mypy`
clean, checker verdict (acceptance criteria + API docs + security regression
review), conventional commit on `feat/full-api-coverage`, PR into `dev`.
Dependency order: **B1** first; **B2, B3, B6, B7** in parallel; **B4**; then
**B5, B8, B9, B10, B11** in parallel; then the **security phase (S1-S3)**.
| Chunk | Scope | Acceptance criteria |
|---|---|---|
| B1 Foundation | `uv` + `uv.lock`, `mcp>=2.3,<3`, ruff format (black removed), ruff `S` rules, new contract in `AGENTS.md`/`CLAUDE.md`, ADRs, delete legacy modules and their tests, interface skeletons (`catalog/model.py`, `identity.py`, new `config.py`). | `uv run pytest` green on the remaining modules, lint clean, no import of removed modules anywhere. |
| B2 Catalog | `scripts/generate_catalog.py` builds `catalog/tools.json` from the pinned swagger: names, domains, JSON input schemas (`additionalProperties: false`, owner/repo patterns), annotations, content types. Loader in `catalog/__init__.py`. `--check` mode. | All 537 operations present, names unique, match `^gitea_[a-z0-9_]+$`, at most 64 chars; schemas validate as Draft 2020-12; unit tests for naming, schema building, ref resolution. |
| B3 Executor | `gitea/executor.py`: path templating with encoding, query/JSON/multipart (base64 files), Sudo header, pagination metadata (`Link`, `X-Total-Count`), text and binary responses, error mapping with sanitized messages, response limits, secret masking, untrusted-content envelope with identity. | `pytest-httpx` unit tests for each branch, tokens never present in any error or result. |
| B4 Server core + transports | Low-level SDK `Server` built from the catalog: `list_tools` with `TOOLSETS`/`READ_ONLY`/`DISABLED_TOOLS`, `call_tool` with jsonschema validation, audit, executor, `is_error` results. stdio entry, streamable HTTP app with static bearer verifier, `/health`, bind fail-closed, `gitea_api_request` raw tool. | In-memory client tests for list/call/validation/errors; HTTP tests for 401 without/with bad token, refusal to start on a non-loopback bind without auth. |
| B5 Per-user OAuth | `OAuthAuthorizationServerProvider` proxying Gitea (DCR on, PKCE to Gitea), `/oauth/callback`, encrypted token store (file, 0600), refresh handling, combined verifier (static bearer = admin identity, OAuth token = user identity), identity in result metadata. | Unit tests with mocked Gitea OAuth endpoints: full code flow, refresh, expiry, revoke, store permissions and encryption, tokens never logged. |
| B6 Setup command | `aegis-gitea-mcp setup`: reachability, admin and token-scope check (`GET /user`, `GET /token`), OAuth app registration via `POST /user/applications/oauth2`, writes `.env` from `.env.example` (0600), prints client configs and a compose snippet, never echoes secrets. | Unit tests with mocked Gitea; integration test against the CI instance (in B8). |
| B7 Integration harness | Pytest fixtures that start `gitea/gitea:28.1.0` (digest-pinned) with unique container/network names and guaranteed cleanup, seed an admin user and a full-scope token, plus fake-data seed helpers. Marker `integration`, skipped unless enabled. | Harness starts, seeds, tears down on this host and in CI; no bind mounts, no prune. |
| B8 Integration suite | Scenario tests (org, repo, issue, PR, review, merge, release, secret, variable, webhook, cleanup) and a sweep that calls every tool once with fixture-derived arguments, asserting either success or a documented expected status. Per-user mode via minted user tokens. Setup command against the live instance. | Every registered tool exercised; write tools have happy and failure paths; non-admin user gets Gitea's own 403 on admin tools; identity visible. |
| B9 MCP smoke tests | Real SDK clients over stdio (subprocess) and streamable HTTP (uvicorn, and the built image) listing tools and calling a read and a write tool. | Green locally and in CI. |
| B10 CI | `.gitea/workflows/`: gitleaks, lint, test-unit, test-integration, test-mcp, build-and-push (SHA tags, digest output), docs-check, security; strict per-run cleanup, no prune. Dockerfile on `uv`, non-root, no secrets in layers. | All eight jobs green on the PR. |
| B11 Docs | README rewrite (honest scope, setup with time estimate, stdio and HTTP client configs for Claude, Claude Code, Mistral, local tools, token scopes, untrusted-content notice), generated tool inventory `docs/tools.md` with endpoint mapping, `scripts/check_docs.py` drift check, deployment and reverse-proxy guide. | Docs check green; no stale claims left from section 1.9. |
| S1 Threat model | `docs/security.md`: assets, trust boundaries, threats, mitigations. | Reviewed by the checker. |
| S2 Fixes and negative tests | Security test suite: auth negatives, bind refusal, token redaction in logs/errors/results, secrets never echoed, malformed input rejected, base URL validation. | All green. |
| S3 Scans | `pip-audit`, ruff `S`/bandit, gitleaks over history; findings fixed or waived with reasons in `docs/security.md`. | Scans clean or waived. |
## 4. Risks and open questions
Owner decisions taken before execution (2026-10-14):
- **Security contract reversed.** All capabilities on by default; the owner
restricts via configuration and token choice (ADR 0001).
- **Framework.** Rebuild on the official SDK (ADR 0002).
- **Commit signing.** Disabled by the owner on `dev`; commits are unsigned.
- **Tool count.** One tool per swagger operation (537 plus the raw request
tool), all on by default, with a toolset filter (ADR 0003).
Open risks:
1. **Tool-list size.** 538 tools is a large `tools/list` payload. Claude Code
handles it with tool search; claude.ai and Mistral Le Chat may degrade or
truncate. Mitigation: `TOOLSETS`, `READ_ONLY` and `DISABLED_TOOLS`; the README
gives a recommended slim profile for Le Chat. Needs owner testing against
the real clients.
2. **"FastMCP" no longer exists.** In `mcp` 2.x FastMCP was renamed
`MCPServer`, and it cannot register tools from raw JSON schemas. We use the
SDK's low-level `Server`, which is the same package and transport stack
(ADR 0002).
3. **Gitea has no dynamic client registration.** Per-user OAuth therefore needs
our server to be the OAuth authorization server for MCP clients and a Gitea
OAuth client upstream. The server holds per-user Gitea tokens, which makes
the token store a high-value asset (encrypted at rest, ADR 0004).
4. **Gitea scope fallback.** Gitea grants full access for unknown scope
strings. The proxy sends a fixed, validated scope list.
5. **Literal "every endpoint gets an integration test".** Some operations need
state that is costly to build in CI: Actions runs need a registered runner
executing a job, push mirrors and migrations need a reachable remote,
package endpoints need an uploaded package, unadopted repositories need files
on the Gitea volume. The sweep exercises every tool; where a full happy path
is not reproducible in CI, the test asserts the documented Gitea response
(for example 404 for a missing artifact) and the README marks it.
6. **Shared CI runner.** Integration and MCP jobs need Docker from the job
container. Per-run unique names and `always()` cleanup; the existing
`docker image prune` / `builder prune` steps are removed.
7. **Breaking change.** All tool names change (`gitea_<domain>_<action>`), the
FastAPI server, YAML policy and API keys are removed. Version goes to 1.0.0.
8. **Admin coverage limits.** The API has no queue management or instance
configuration write endpoints, and no instance-wide OAuth app endpoint; setup
registers the OAuth app as a user application owned by the admin.