diff --git a/.env.example b/.env.example index 2dec356..9b81f1b 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,17 @@ WRITE_MODE=false WRITE_REPOSITORY_WHITELIST= WRITE_ALLOW_ALL_TOKEN_REPOS=false +# Raw API dispatch (gitea_request escape hatch). See docs/raw-api.md. +# gitea_request can call any Gitea REST endpoint (method + path). It is still +# subject to policy.yaml, WRITE_MODE + the write whitelist, and a built-in +# admin/credential denylist. Set RAW_API_ENABLED=false to remove the tool's +# ability to dispatch entirely. +RAW_API_ENABLED=true +# Allow gitea_request to reach admin/credential surfaces (/admin, *tokens*, +# *secrets*, *hooks*, *keys*, applications/oauth2, runner registration tokens). +# Leave false unless you fully understand the exposure. +RAW_API_ALLOW_SENSITIVE=false + # Automation mode (disabled by default) AUTOMATION_ENABLED=false AUTOMATION_SCHEDULER_ENABLED=false diff --git a/docs/api-reference.md b/docs/api-reference.md index 1691279..0519336 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -90,8 +90,18 @@ Scope requirements: - `create_milestone` (`owner`, `repo`, `title`, optional `description`, `due_on`) - `edit_issue_comment` (`owner`, `repo`, `comment_id`, `body`) -Not supported by design: merge, branch/label/release deletion, force push, repo/admin -management. +Not supported by the dedicated tools by design: merge, branch/label/release deletion, +force push, repo/admin management. Endpoints not covered above are reachable through the +generic `gitea_request` escape hatch (subject to policy, write-mode, and a sensitive-path +denylist) — see [Raw API Dispatch](raw-api.md). + +## Raw API Dispatch + +- `gitea_request` (`method`, `path`, optional `query`, `body`) + - Calls an arbitrary Gitea REST endpoint. `GET`/`HEAD` are reads; other methods are + writes and require write-mode plus a whitelisted repository. Admin/credential + endpoints are blocked unless `RAW_API_ALLOW_SENSITIVE=true`. See + [Raw API Dispatch](raw-api.md) for the two-layer policy model and full details. Note: `create_issue`, `add_labels`, and `remove_labels` accept label **names**; the server resolves them to Gitea label ids and returns a clear error for unknown labels. diff --git a/docs/index.md b/docs/index.md index 0ad7f8b..b8246f3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,6 +17,7 @@ AegisGitea MCP acts as a secure bridge between AI assistants (such as Claude, Cl | [Getting Started](getting-started.md) | Installation and first-time setup | | [Configuration](configuration.md) | All environment variables and settings | | [API Reference](api-reference.md) | HTTP endpoints and MCP tools | +| [Raw API Dispatch](raw-api.md) | The generic `gitea_request` escape-hatch tool | | [Architecture](architecture.md) | System design and data flow | | [Security](security.md) | Authentication, rate limiting, and audit logging | | [Deployment](deployment.md) | Docker and production deployment | diff --git a/docs/raw-api.md b/docs/raw-api.md new file mode 100644 index 0000000..951c5cf --- /dev/null +++ b/docs/raw-api.md @@ -0,0 +1,119 @@ +# Raw API Dispatch (`gitea_request`) + +`gitea_request` is a generic escape hatch that can call **any** Gitea REST +endpoint by method and path. It exists for the long tail of the Gitea API that +the curated, typed tools do not cover (merging PRs, reviews, writing files, +webhooks, branch/tag protections, collaborators, Actions/CI, packages, +notifications, and so on). + +> Prefer the dedicated tools whenever one exists. Use `gitea_request` only for +> endpoints they do not cover. It is subject to policy, write-mode, and the +> sensitive-path denylist described below. + +## Arguments + +| Field | Type | Notes | +|-------|------|-------| +| `method` | enum | `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE` (case-insensitive). Any other method is rejected before any network call. | +| `path` | string | Gitea REST path. The `/api/v1` prefix is optional. A full URL may be supplied — the host and query string are stripped. | +| `query` | object | Optional query-string parameters. | +| `body` | object | Optional JSON request body. **Never logged.** | + +The response is returned in a stable envelope: + +```json +{ + "method": "GET", + "path": "/api/v1/repos/acme/app/pulls/1", + "write": false, + "repository": "acme/app", + "data": { "...": "..." } +} +``` + +List responses add `count` and `omitted`; oversized objects are returned as a +truncated JSON string with `"truncated": true`. All responses are bounded by +`MAX_TOOL_RESPONSE_ITEMS` / `MAX_TOOL_RESPONSE_CHARS`. + +## Two-layer authorization + +A single tool surface would normally collapse the granularity of `policy.yaml`. +To preserve it, every call is authorized twice: + +1. **Central gate (`server.py`).** The registered `gitea_request` tool name is + allowed/denied like any other tool. In service-PAT mode the central gate also + parses the target repository from the path and verifies that the signed-in + user has permission on that repository before the service PAT is used. +2. **Handler gate (`raw_tools.py`).** The handler derives a coarse **virtual + tool name** of the form `gitea_request::` (for + example `gitea_request:GET:repos` or `gitea_request:DELETE:repos`) and runs + it back through the policy engine with the parsed repository, target path, and + a `is_write` flag (`true` for any method other than GET/HEAD). This reuses the + existing write-mode + write-whitelist enforcement and lets `policy.yaml` allow + or deny raw dispatch per method and per top-level path segment. + +Because the policy engine matches tool names by **exact set membership** (only +`paths` use globbing), the virtual name is deliberately coarse and stable. + +### Example: lock raw dispatch to reads + +```yaml +tools: + deny: + - gitea_request:POST:repos + - gitea_request:PUT:repos + - gitea_request:PATCH:repos + - gitea_request:DELETE:repos +``` + +## Sensitive-path denylist + +Independently of `policy.yaml`, the handler blocks endpoints that touch an +admin or credential surface **for every method, including GET** (a GET on these +already leaks credentials or privileged configuration): + +- `/admin` +- `*tokens*` +- `*secrets*` +- `*hooks*` +- `*keys*` (and `*gpg_keys*`) +- `applications/oauth2` +- `actions/runners/registration-token` + +This denylist lives in the handler and **cannot be re-opened from +`policy.yaml`.** It is overridden only by setting `RAW_API_ALLOW_SENSITIVE=true`. + +## Configuration + +| Variable | Default | Notes | +|----------|---------|-------| +| `RAW_API_ENABLED` | `true` | Killswitch. When `false`, `gitea_request` refuses every dispatch with a `403`. | +| `RAW_API_ALLOW_SENSITIVE` | `false` | When `true`, the admin/credential denylist is bypassed. Leave `false` unless you fully understand the exposure. | + +## Security warning + +> With `WRITE_MODE=true`, the **write whitelist is the only brake** on +> `POST`/`PUT`/`PATCH`/`DELETE` across the *entire* Gitea API surface reachable +> by `gitea_request`. Any write method against a whitelisted repository will be +> attempted. Keep the whitelist tight, prefer denying the write virtual tool +> names in `policy.yaml`, and keep `RAW_API_ALLOW_SENSITIVE=false`. + +## Behavioral notes and edge cases + +- **Full URL supplied instead of a path:** only the path is used; the host and + query string are discarded (`query` carries query parameters). +- **Path traversal (`..`):** rejected during argument validation (`400`). +- **Unknown / non-HTTP method:** rejected during argument validation, before any + network call. +- **Cross-repo endpoints** such as `/repos/search` and `/repos/issues/search` + are intentionally *not* treated as repository-scoped, so `repository` is + `null` for them. +- **Non-repository writes** such as `POST /user/repos` or `POST /orgs` are denied + with *"write operation requires a repository target"*. This is the secure + default — the per-user permission model is repository-scoped, so there is no + repository against which to verify the write. This behavior is intentional and + is not worked around. +- **Service-PAT mode:** non-repository endpoints (for example `GET /user/orgs`) + are denied by the central gate because per-user permission can only be verified + against a repository target. Use the dedicated tools for those, or run in + OAuth-only mode. diff --git a/policy.yaml b/policy.yaml index 8fc0613..9e1028d 100644 --- a/policy.yaml +++ b/policy.yaml @@ -4,5 +4,20 @@ defaults: tools: deny: [] + # The generic `gitea_request` tool authorizes each call under a coarse virtual + # tool name of the form `gitea_request::`, e.g. + # `gitea_request:GET:repos` or `gitea_request:DELETE:repos`. To keep raw + # dispatch read-only while still allowing GETs, deny the write methods here: + # + # deny: + # - gitea_request:POST:repos + # - gitea_request:PUT:repos + # - gitea_request:PATCH:repos + # - gitea_request:DELETE:repos + # + # NOTE: The admin/credential denylist (/admin, *tokens*, *secrets*, *hooks*, + # *keys*, applications/oauth2, runner registration tokens) is enforced in the + # handler independently of this file and is NOT configured here. It can only be + # overridden by setting RAW_API_ALLOW_SENSITIVE=true. repositories: {}