gitea_cleanup_post_merge_moot_lease (#515) posts a terminal `phase: released` lease marker — a durable mutation of the PR lease ledger — but had no entry in the canonical task-capability map and no role binding. Entry gated on gitea.read, apply gated only on gitea.pr.comment, so any profile holding the comment permission (author, reviewer, merger) reached the mutation path, while the reconciler could not satisfy the operator-required resolve-exact-task -> mutation sequence because no cleanup task was resolvable at all. The apply path also called verify_preflight_purity(remote) with no task/org/repo, skipping the resolved-task, canonical-root and explicit-target checks its siblings perform, and passed request org/repo straight into _resolve. Capability map and router: - Map `cleanup_post_merge_moot_lease` and the tool-name alias `gitea_cleanup_post_merge_moot_lease` to gitea.pr.comment + reconciler. Both names carry an identical contract; unknown names keep failing closed on the map's KeyError. - Add both to role_session_router RECONCILER_TASKS and TASK_REQUIRED_ROLE so the map and the router cannot disagree (the #723 defect-A class). Tool enforcement (apply path only): - Require the session to have resolved exactly the cleanup task; resolving a different task, including a sibling reconciler task, does not authorize it. - Require the reconciler role, checked independently of the permission gate. - Require gitea.pr.comment. - Validate explicit org/repo against the canonical repository identity derived from the session binding, so request parameters can never redirect the mutation, and forward worktree_path/task/org/repo to verify_preflight_purity for canonical-root, workspace and anti-stomp binding (#733/#739). - Require matching dry-run evidence proving lease_moot and cleanup_allowed for the same PR, lease session, candidate head and lease marker id, with optional caller expectations checked against the live lease. New post_merge_moot_lease_gate module holds the pure authorization logic and an append-only dry-run ledger: entries are only ever appended, lookup is newest-wins, and dry_run_history hands out copies. Live, non-moot, superseded, mismatched, malformed and foreign-repository leases all fail closed; already-terminal cleanup stays idempotent. The read-only apply=false assessment deliberately stays reachable under gitea.read with no role gate, matching gitea_cleanup_stale_review_decision_lock and gitea_cleanup_obsolete_reviewer_comment_lease, so an operator can diagnose a stuck lease from any attached namespace. This choice is documented in the map, the tool docstring and docs/gitea-execution-profiles.md, and is directly tested. _delete_branch_repository_binding_block is generalized into _repository_binding_block(required_permission=...) and retained as a thin delete-path alias so #733/#739 coverage keeps exercising its permission label. Tests: new tests/test_issue_745_moot_lease_reconciler_gate.py (39 tests, 35 subtests) covers the map/router contract, alias parity, unknown-name rejection, role and task gates, dry-run/apply sequencing, superseded and malformed leases, repository binding, ledger append-only behavior, idempotency, and asserts no production PR/session/marker is referenced. tests/test_post_merge_moot_lease.py is updated from the permission-only model to reconciler + dry-run evidence, with a new negative test pinning that a merger can no longer apply. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
630 lines
29 KiB
Markdown
630 lines
29 KiB
Markdown
# Gitea MCP Execution Profiles
|
||
|
||
## Purpose
|
||
|
||
This document defines the **task-scoped execution profile model** for the
|
||
`gitea-mcp` package of the MCP Control Plane. It describes *what a profile is*,
|
||
the metadata each profile carries, and the safety rules that govern which
|
||
profile may perform which Gitea operation.
|
||
|
||
This issue defines the **model only**. It does not implement runtime profile
|
||
loading, profile switching, or any review/merge behavior — see
|
||
[Relationship to roadmap issues](#relationship-to-roadmap-issues).
|
||
|
||
## Principle: LLMs are not roles
|
||
|
||
The central rule of this model:
|
||
|
||
```text
|
||
The LLM is not the role.
|
||
The MCP credential/profile used for the task is the role.
|
||
```
|
||
|
||
An LLM session is not permanently an "author," a "reviewer," or a "merger."
|
||
Any LLM session may perform any of these roles — but only while operating
|
||
through a **task-appropriate execution profile** whose authenticated Gitea
|
||
identity and allowed operations fit the task.
|
||
|
||
Consequences:
|
||
|
||
- A task selects a profile; a profile is not assigned to a model.
|
||
- The same LLM may act as author for one task and reviewer for another, by
|
||
using different profiles — never by escalating a single profile.
|
||
- Two roles that must not be held by one identity (e.g. author and merger of
|
||
the same PR) are separated by using **different authenticated identities**,
|
||
not by trusting the LLM to behave.
|
||
|
||
## Profile model
|
||
|
||
A Gitea MCP execution profile is a named, declarative description of an
|
||
authenticated capability set. Each profile defines the following fields:
|
||
|
||
| Field | Type | Meaning |
|
||
|-------|------|---------|
|
||
| `profile_name` | string | Stable identifier, e.g. `gitea-reviewer`. |
|
||
| `authenticated_username` | string | The Gitea login this profile authenticates as (verified at runtime via `gitea_whoami`, not trusted from config). |
|
||
| `allowed_operations` | list | Operation categories this profile may perform. |
|
||
| `forbidden_operations` | list | Operation categories this profile must never perform. |
|
||
| `allowed_repositories` | list | Optional. Canonical `owner/repository` slugs this profile may bind to. An authorization boundary, not the binding itself — see [Repository scope](#repository-scope-714). |
|
||
| `token_source_name` | string | The *name* of the secret source (e.g. env var name or secret key). **Never the token value.** |
|
||
| `audit_label` | string | Short label attached to audit records for actions by this profile. |
|
||
| `can_approve_prs` | bool | May submit an approving PR review. |
|
||
| `can_merge_prs` | bool | May merge a PR. |
|
||
| `can_push_branches` | bool | May push branches / create commits. |
|
||
| `can_mutate_issues` | bool | May create/edit/label/close issues and comment. |
|
||
| `can_author_impl_prs` | bool | May author implementation PRs (branch + commit + open PR). |
|
||
|
||
`token_source_name` records **where** a token comes from (a variable or key
|
||
name), never the token itself. Token values are never part of a profile object,
|
||
never logged, never returned by a tool, and never committed.
|
||
|
||
## Repository scope (#714)
|
||
|
||
`allowed_repositories` declares the canonical `owner/repository` slugs a profile
|
||
may operate on:
|
||
|
||
```json
|
||
"prgs-author": {
|
||
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"]
|
||
}
|
||
```
|
||
|
||
It is an **authorization boundary, not the session binding**. The binding is
|
||
derived and enforced like this:
|
||
|
||
1. The session repository is derived from the **verified, workspace-aligned git
|
||
remote** — never from a caller-supplied `org`/`repo` argument, and never from
|
||
the `REMOTES` table (whose entries are default *targets*: `prgs` defaults to
|
||
`Timesheet`, which is not this project).
|
||
2. That workspace-derived slug is validated against `allowed_repositories`.
|
||
3. The session binds immutably to that one canonical `owner/repository`. The
|
||
organization is derived from the slug; there is no independent,
|
||
caller-controlled organization value.
|
||
4. If a profile authorizes several repositories, the verified workspace still
|
||
selects exactly one. A session never binds to the whole list and never
|
||
switches between entries.
|
||
|
||
Fail-closed rules:
|
||
|
||
- Activation is rejected when the workspace repository is absent from the
|
||
allowlist.
|
||
- A mutation is rejected when no verified workspace repository can be
|
||
established.
|
||
- A tool-level `org`/`repo` override that disagrees with the binding is rejected
|
||
before the mutation runs.
|
||
- A mutation request can never establish, complete, or replace the binding.
|
||
|
||
The field is **config-only**: no environment variable can widen or forge it. It
|
||
is optional — a profile that omits it keeps the previous behaviour, so existing
|
||
static-profile namespaces stay functional until an operator provisions the
|
||
scope. Provisioning it is what activates enforcement for that profile.
|
||
|
||
## Example profiles
|
||
|
||
The following are the reference profiles. Booleans express intended capability
|
||
boundaries; they are the model, not a runtime enforcement mechanism yet.
|
||
|
||
### `gitea-issue-manager`
|
||
|
||
- **allowed:** `read`, `issue.create`, `issue.comment`, `issue.label`, `issue.close`
|
||
- **forbidden:** `pr.approve`, `pr.merge`, `branch.push`
|
||
- `can_approve_prs`: `false`
|
||
- `can_merge_prs`: `false`
|
||
- `can_push_branches`: `false`
|
||
- `can_mutate_issues`: `true`
|
||
- `can_author_impl_prs`: `false`
|
||
|
||
### `gitea-author`
|
||
|
||
- **allowed:** `read`, `branch.push`, `pr.create`, `pr.comment`, `issue.comment`
|
||
- **forbidden:** `pr.approve`, `pr.merge`
|
||
- `can_approve_prs`: `false`
|
||
- `can_merge_prs`: `false`
|
||
- `can_push_branches`: `true`
|
||
- `can_mutate_issues`: `false` (may comment, may not manage)
|
||
- `can_author_impl_prs`: `true`
|
||
|
||
### `gitea-reviewer`
|
||
|
||
- **allowed:** `read`, `pr.comment`, `pr.review`, `pr.approve`, `pr.request_changes`, `issue.comment`
|
||
- **forbidden:** `pr.merge`, `branch.push`
|
||
- `can_approve_prs`: `true`
|
||
- `can_merge_prs`: `false`
|
||
- `can_push_branches`: `false`
|
||
- `can_mutate_issues`: `false`
|
||
- `can_author_impl_prs`: `false`
|
||
|
||
### `gitea-merger`
|
||
|
||
- **allowed:** `read`, `pr.merge`
|
||
- **forbidden:** `pr.approve`, `branch.push`, `pr.create`
|
||
- `can_approve_prs`: `false` (a merger must not also be the sole approver)
|
||
- `can_merge_prs`: `true`
|
||
- `can_push_branches`: `false`
|
||
- `can_mutate_issues`: `false`
|
||
- `can_author_impl_prs`: `false`
|
||
|
||
### `gitea-owner`
|
||
|
||
- **allowed:** broad administrative access; use sparingly and never for routine
|
||
LLM workflow tasks.
|
||
- **forbidden:** nothing structurally, which is exactly why it must not be the
|
||
default profile for automated work.
|
||
- `can_approve_prs`: `true`
|
||
- `can_merge_prs`: `true`
|
||
- `can_push_branches`: `true`
|
||
- `can_mutate_issues`: `true`
|
||
- `can_author_impl_prs`: `true`
|
||
|
||
> `gitea-owner` exists for human/administrative use. Automated LLM workflows
|
||
> should prefer the narrowest sufficient profile. An all-powerful profile is a
|
||
> convenience, not a role, and it does not exempt a session from the
|
||
> self-review / self-merge rule below.
|
||
|
||
## Allowed and forbidden operations
|
||
|
||
Operations are grouped into coarse categories so profiles stay readable:
|
||
|
||
- `read` — view issues, PRs, files, identity (`gitea_whoami`).
|
||
- `issue.*` — `create`, `comment`, `label`, `close`.
|
||
- `pr.*` — `create`, `comment`, `review`, `approve`, `request_changes`, `merge`.
|
||
- `branch.push` — push branches / create commits.
|
||
|
||
Rules:
|
||
|
||
- `forbidden_operations` always wins over `allowed_operations`. If an operation
|
||
appears in both, it is forbidden.
|
||
- An operation not present in `allowed_operations` is treated as **not
|
||
allowed** (deny by default).
|
||
|
||
## Operation-name normalization (#106)
|
||
|
||
Canonical operation names are namespaced: `{service}.{area}.{verb}` (e.g.
|
||
`gitea.pr.merge`, `jenkins.build.read`). Legacy unqualified spellings are
|
||
accepted **only** through the explicit alias table below (the code of record
|
||
is `GITEA_OPERATION_ALIASES` in `gitea_config.py`; the enforcement matrix is
|
||
`tests/test_op_normalization.py`).
|
||
|
||
| Legacy spelling | Canonical operation |
|
||
|-------------------|----------------------------|
|
||
| `read` | `gitea.read` |
|
||
| `review` | `gitea.pr.review` |
|
||
| `comment` | `gitea.pr.comment` |
|
||
| `approve` | `gitea.pr.approve` |
|
||
| `request_changes` | `gitea.pr.request_changes` |
|
||
| `merge` | `gitea.pr.merge` |
|
||
| `pr.create` | `gitea.pr.create` |
|
||
| `branch.push` | `gitea.branch.push` |
|
||
| `branch` | `gitea.branch.create` |
|
||
| `commit` | `gitea.repo.commit` |
|
||
| `push` | `gitea.branch.push` |
|
||
| `open_pr` | `gitea.pr.create` |
|
||
|
||
For non-Gitea services, a single unqualified word namespaces to the checked
|
||
service (`read` → `jenkins.read` when checking Jenkins); names already
|
||
prefixed with that service pass through unchanged.
|
||
|
||
Enforcement rules (`gitea_config.check_operation`, run **before** any
|
||
allowed/forbidden membership check):
|
||
|
||
- Unknown operation names fail closed (denied).
|
||
- Ambiguous names — dotted names that are neither service-prefixed nor in the
|
||
alias table — fail closed.
|
||
- Cross-service names are never accepted by the wrong service
|
||
(`jenkins.read` never matches a Gitea check, and a Gitea alias is never
|
||
applied to another service).
|
||
- `forbidden_operations` overrides `allowed_operations` after both sides are
|
||
normalized, so a legacy spelling can never bypass a canonical forbidden
|
||
entry (or vice versa).
|
||
- An allowed entry that cannot be normalized grants nothing; a forbidden
|
||
entry that cannot be normalized denies the request. Normalization can
|
||
therefore never silently widen permissions.
|
||
- An empty or missing `allowed_operations` list denies everything.
|
||
|
||
## Issue comments versus PR reviews (#126)
|
||
|
||
Issue discussion comments and PR reviews are different capabilities and are
|
||
gated by different operations:
|
||
|
||
- **Issue comments** (`gitea_list_issue_comments`, `gitea_create_issue_comment`)
|
||
post to and read from an issue's discussion thread
|
||
(`/issues/{n}/comments`). Listing requires `gitea.read`; creating requires
|
||
`gitea.issue.comment`. They never submit review verdicts.
|
||
- **PR reviews** (`gitea_review_pr`, `gitea_submit_pr_review`) submit
|
||
approve/request-changes/comment verdicts on pull requests
|
||
(`/pulls/{n}/reviews`) and are gated by the `gitea.pr.*` family
|
||
(`gitea.pr.review`, `gitea.pr.approve`, `gitea.pr.request_changes`,
|
||
`gitea.pr.comment`).
|
||
|
||
A profile holding the full PR review/merge set still cannot post issue
|
||
discussion comments unless it also allows `gitea.issue.comment`, and vice
|
||
versa — neither family implies the other. Both comment tools require an
|
||
explicit issue number; the target repo comes only from the standard
|
||
remote/org/repo arguments. Create operations are audit-logged
|
||
(`create_issue_comment`) when `GITEA_AUDIT_LOG` is configured, errors are
|
||
redacted, and normal output contains no endpoint URLs
|
||
(`GITEA_MCP_REVEAL_ENDPOINTS=1` is the local admin opt-in for web links).
|
||
|
||
## PR edits versus PR closure (#216)
|
||
|
||
Editing a pull request and closing one are different capabilities:
|
||
|
||
- **PR edits** (`gitea_edit_pr` with `title`/`body`/`base`, or reopening with
|
||
`state="open"`) stay on the ordinary edit path and need no dedicated
|
||
capability.
|
||
- **PR closure** (`gitea_edit_pr` with `state="closed"`) requires the
|
||
distinct `gitea.pr.close` operation. The resolver task is `close_pr`
|
||
(`gitea_resolve_task_capability(task="close_pr")`, author-side). Without
|
||
`gitea.pr.close` the close attempt fails closed — no API call, structured
|
||
`permission_report` — so the broad edit path can never be used as an
|
||
untracked close fallback.
|
||
- Closures are audited as a distinct `close_pr` action with
|
||
`required_permission: gitea.pr.close` in the request metadata, so final
|
||
reports can prove exactly which mutation capability was exercised (#191).
|
||
|
||
`gitea.pr.close` has no legacy alias; spell it canonically. It is not part of
|
||
any default profile: the operator grants it deliberately (e.g. for an
|
||
explicit operator-directed closure of a contaminated PR). If `close_pr` ever
|
||
resolves as unknown, agents must fail closed rather than fall back to the
|
||
edit path.
|
||
|
||
## Reconciler profile for already-landed open PRs (#304 / #310)
|
||
|
||
Normal author and reviewer profiles must not gain broad `gitea.pr.close`
|
||
authority. Already-landed open PRs (head SHA is an ancestor of the target
|
||
branch) need a dedicated reconciler profile such as `prgs-reconciler` with a
|
||
narrow operation set:
|
||
|
||
- `gitea.read`
|
||
- `gitea.pr.comment`
|
||
- `gitea.issue.comment`
|
||
- `gitea.issue.close`
|
||
- `gitea.pr.close`
|
||
- `gitea.branch.delete` (merged-branch cleanup only — see below)
|
||
|
||
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
|
||
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
|
||
`gitea.repo.commit`.
|
||
|
||
### Merged-branch cleanup ownership (`gitea.branch.delete`)
|
||
|
||
The reconciler is the repository-supported owner of merged-PR source-branch
|
||
cleanup: `task_capability_map` maps `cleanup_merged_pr_branch` (and
|
||
`reconciliation_cleanup`) to role `reconciler` with permission
|
||
`gitea.branch.delete`. Post-merge branch lifecycle is reconciliation work —
|
||
it happens after the author, reviewer, and merger roles have completed, and
|
||
it must not be reachable from those roles.
|
||
|
||
Least-privilege constraints:
|
||
|
||
- `gitea.branch.delete` is granted **only** to reconciler profiles. Author,
|
||
reviewer, and merger profiles must never hold it; `gitea_delete_branch`
|
||
and `gitea_cleanup_merged_pr_branch` fail closed on any profile without
|
||
the permission.
|
||
- Even with the permission, reconciler deletion is only supported through the
|
||
guarded `gitea_cleanup_merged_pr_branch` path (#514 / #687): the PR must be
|
||
merged, the head an ancestor of the target, the branch not protected
|
||
(`master`/`main`/`dev`), the branch not a preservation/evidence ref (e.g.
|
||
`chore/issue-681-preserve-review-session-wip`), no open PR may still use the
|
||
head, and an explicit `CLEANUP MERGED PR <n> BRANCH <branch>` confirmation is
|
||
required. Raw `gitea_delete_branch` is **denied** to reconciler even when
|
||
`gitea.branch.delete` is present.
|
||
- Raw `git branch -d` / `git push --delete` cleanup remains blocked by
|
||
`branch_cleanup_guard` and the final-report validator regardless of
|
||
profile permissions.
|
||
- `gitea.branch.delete` has no short alias in `GITEA_OPERATION_ALIASES`;
|
||
write it fully qualified in `allowed_operations`. Migration must emit
|
||
canonical names such as `gitea.pr.close` (never bare `pr.close` /
|
||
`issue.close`, which the production normalizer rejects or drops).
|
||
|
||
### Post-merge moot-lease cleanup ownership (`gitea.pr.comment`)
|
||
|
||
Neutralising a reviewer lease left behind on an already-merged/closed PR is
|
||
reconciliation work too. `task_capability_map` maps
|
||
`cleanup_post_merge_moot_lease` — and its tool-name alias
|
||
`gitea_cleanup_post_merge_moot_lease` — to role `reconciler` with permission
|
||
`gitea.pr.comment` (#745). Both names carry the **same** contract.
|
||
|
||
The permission alone is deliberately not sufficient: author, reviewer and
|
||
merger profiles all hold `gitea.pr.comment` for ordinary PR discussion, so the
|
||
role gate — not the permission gate — is what keeps the terminal lease marker
|
||
reconciler-owned.
|
||
|
||
`gitea_cleanup_post_merge_moot_lease` splits its two modes on purpose:
|
||
|
||
- **`apply=false` (assessment) requires only `gitea.read`, with no role gate.**
|
||
This matches `gitea_cleanup_stale_review_decision_lock` and
|
||
`gitea_cleanup_obsolete_reviewer_comment_lease`, whose assessment paths are
|
||
likewise read-gated, so an operator can diagnose a stuck lease from whichever
|
||
namespace happens to be attached without switching roles. The dry run
|
||
performs no mutation and records append-only evidence in-session.
|
||
- **`apply=true` (mutation) requires all of the following**, in order: the
|
||
session must have resolved exactly `cleanup_post_merge_moot_lease` (resolving
|
||
any other task — including a sibling reconciler task — does not authorize
|
||
it); the active role must be `reconciler`; the profile must hold
|
||
`gitea.pr.comment`; the explicit `org`/`repo` must agree with the canonical
|
||
repository identity, which is derived from the session binding and can never
|
||
be overridden by request parameters; and matching dry-run evidence must show
|
||
`lease_moot`, `cleanup_allowed`, and the same PR, lease session, candidate
|
||
head and lease marker id that are live at apply time.
|
||
|
||
Everything else fails closed: a live lease on an open PR, an already-terminal
|
||
(idempotent) lease, a lease superseded between the dry run and the apply, a
|
||
malformed lease missing session/head/marker, and any foreign-repository target.
|
||
The cleanup only ever appends a terminal `phase: released` marker
|
||
(`blocker: post-merge-moot`) — it never edits or deletes another session's
|
||
comment, and it never merges or adopts a lease.
|
||
|
||
Launch a static `gitea-reconciler` MCP namespace with
|
||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
||
`gitea_reconcile_already_landed_pr` tool (#310). The resolver task is
|
||
`reconcile_already_landed_pr`. PR close is allowed only after live PR fetch,
|
||
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
|
||
heads are not already landed cannot be closed through this path.
|
||
|
||
### Operational runbook: grant reconciler `gitea.branch.delete` (#687)
|
||
|
||
Merging a code PR that updates `migrate_profiles.py` / `reconciler_profile.py`
|
||
**does not** change the live operator profile on disk. Apply the profile
|
||
change deliberately, then reconnect the client-managed namespace.
|
||
|
||
1. **Approved migration / profile-update command** (from the repo root, using
|
||
the project venv if present):
|
||
|
||
```bash
|
||
# Dry-run first (default): validates v2 output, writes nothing
|
||
python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json
|
||
|
||
# Apply: creates backup then writes migrated v2 config
|
||
python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json -w
|
||
# Optional explicit paths:
|
||
# python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json \
|
||
# -o ~/.config/gitea-tools/profiles.json \
|
||
# --backup ~/.config/gitea-tools/profiles.json.bak -w
|
||
```
|
||
|
||
If the live file is already v2, edit the reconciler identity’s
|
||
`allowed_operations` / `forbidden_operations` under
|
||
`environments.<env>.services.gitea.identities.reconciler` (or the
|
||
`prgs-reconciler` alias target) so allowed includes the canonical set
|
||
below — then re-validate with a load of the config (see step 3).
|
||
|
||
2. **Inspect the generated (or edited) profile** — confirm the reconciler
|
||
identity, for example:
|
||
|
||
```bash
|
||
python3 - <<'PY'
|
||
import json
|
||
from pathlib import Path
|
||
cfg = json.loads(Path.home().joinpath(".config/gitea-tools/profiles.json").read_text())
|
||
# v2 environments shape:
|
||
ident = cfg["environments"]["prgs"]["services"]["gitea"]["identities"]["reconciler"]
|
||
print("role:", ident.get("role"))
|
||
print("allowed:", ident.get("allowed_operations"))
|
||
print("forbidden:", ident.get("forbidden_operations"))
|
||
PY
|
||
```
|
||
|
||
3. **Validate canonical operation names and least privilege**
|
||
|
||
Expected canonical **allowed** (defaults after migration):
|
||
|
||
- `gitea.read`
|
||
- `gitea.pr.close` (required)
|
||
- `gitea.pr.comment`
|
||
- `gitea.issue.comment`
|
||
- `gitea.issue.close`
|
||
- `gitea.branch.delete` (recommended; cleanup only)
|
||
|
||
Expected **forbidden** includes at least: `gitea.pr.approve`,
|
||
`gitea.pr.merge`, `gitea.pr.review`, `gitea.pr.create`,
|
||
`gitea.branch.push`, `gitea.repo.commit`.
|
||
|
||
No shorthand (`pr.close`, `issue.close`, `pr.comment`) may remain.
|
||
Validate with the production loader:
|
||
|
||
```bash
|
||
python3 - <<'PY'
|
||
import gitea_config, reconciler_profile
|
||
from pathlib import Path
|
||
path = str(Path.home() / ".config/gitea-tools/profiles.json")
|
||
gitea_config.load_config(path) # fails closed on invalid config
|
||
# Or assess the reconciler lists directly after extracting them:
|
||
# print(reconciler_profile.assess_reconciler_profile(allowed, forbidden))
|
||
PY
|
||
```
|
||
|
||
4. **Merging PR #688 (or any code PR) does not update the live profile.**
|
||
Code changes only the migration helper, schema, docs, and tests. The
|
||
operator must still run `migrate_profiles.py -w` or an equivalent
|
||
authorized edit of `~/.config/gitea-tools/profiles.json`.
|
||
|
||
5. **Supported apply method:** `python3 migrate_profiles.py … -w` (backup
|
||
created automatically) **or** operator-authorized edit of the live
|
||
profiles file after backup. Unsupported: silent mtime tricks, manual
|
||
process kill to “reload”, or undocumented env overrides.
|
||
|
||
6. **Backup and validation:** `-w` copies the input to
|
||
`<input_path>.bak` (or `--backup PATH`) before writing. Re-run
|
||
`load_config` / `assess_reconciler_profile` after write. Keep the
|
||
`.bak` until live whoami/capability checks pass.
|
||
|
||
7. **Client-managed namespace reconnect/reload:** reconnect or reload the
|
||
IDE MCP client so `gitea-reconciler` restarts from current `master` and
|
||
the updated `GITEA_MCP_PROFILE=prgs-reconciler` config. Do not hand-launch
|
||
`mcp_server.py` / `gitea_mcp_server.py` with ad hoc `GITEA_*` env
|
||
(see #686 / #630).
|
||
|
||
8. **Live reverification** (through the client-managed `gitea-reconciler`
|
||
namespace only):
|
||
|
||
- `gitea_whoami` → identity + profile `prgs-reconciler`
|
||
- `gitea_assess_master_parity` → `stale=false`, `restart_required=false`
|
||
- `gitea_resolve_task_capability(task="cleanup_merged_pr_branch")` →
|
||
`allowed_in_current_session=true` only when permission and role match
|
||
- `gitea_resolve_task_capability(task="delete_branch")` →
|
||
**not** allowed for reconciler (role denial must be enforced)
|
||
|
||
9. **Guarded cleanup usage** (example for a merged PR whose source branch
|
||
remains on the remote):
|
||
|
||
```text
|
||
gitea_cleanup_merged_pr_branch(
|
||
pr_number=<N>,
|
||
branch=<exact PR head branch>,
|
||
confirmation="CLEANUP MERGED PR <N> BRANCH <exact PR head branch>",
|
||
remote="prgs",
|
||
org="Scaled-Tech-Consulting",
|
||
repo="Gitea-Tools",
|
||
worktree_path="<path under branches/>",
|
||
)
|
||
```
|
||
|
||
The tool refuses unmerged PRs, protected branches, preservation/evidence
|
||
branches, open-PR heads, mismatched branch names, and wrong confirmation.
|
||
|
||
10. **Prohibitions**
|
||
|
||
- No raw `git push --delete`, `git branch -d` / `-D`, or delete refspecs
|
||
- No arbitrary `gitea_delete_branch` from reconciler
|
||
- No unsupported profile switching mid-run without full re-preflight
|
||
- No ad hoc hand-edits of live profiles **unless** operator-authorized,
|
||
backed up, and revalidated as above
|
||
|
||
Canonical migrated reconciler example:
|
||
|
||
```json
|
||
{
|
||
"role": "reconciler",
|
||
"allowed_operations": [
|
||
"gitea.read",
|
||
"gitea.pr.close",
|
||
"gitea.pr.comment",
|
||
"gitea.issue.comment",
|
||
"gitea.issue.close",
|
||
"gitea.branch.delete"
|
||
],
|
||
"forbidden_operations": [
|
||
"gitea.pr.approve",
|
||
"gitea.pr.merge",
|
||
"gitea.pr.review",
|
||
"gitea.pr.create",
|
||
"gitea.branch.push",
|
||
"gitea.repo.commit"
|
||
]
|
||
}
|
||
```
|
||
|
||
## Identity and fail-closed rules
|
||
|
||
Before **any** mutating action, a workflow must know both:
|
||
|
||
1. **The active profile** — which profile is in effect for this task.
|
||
2. **The authenticated identity** — the real Gitea login, verified via
|
||
`gitea_whoami` (issue #11), not read from configuration and trusted.
|
||
|
||
Fail-closed requirements:
|
||
|
||
- If the active profile is unknown → **stop; do not mutate.**
|
||
- If the authenticated identity cannot be determined → **stop; do not mutate.**
|
||
- If the requested operation is not in the profile's `allowed_operations`, or is
|
||
in `forbidden_operations` → **stop; do not mutate.**
|
||
- Ambiguity is treated as denial. The safe default is always "do not act."
|
||
|
||
Read-only actions may proceed without a resolved profile, but must still never
|
||
expose token or credential material.
|
||
|
||
## Self-review and self-merge prevention
|
||
|
||
A profile/session **must not approve or merge a PR authored by the same
|
||
authenticated Gitea user.**
|
||
|
||
- The check compares the profile's *verified* `authenticated_username`
|
||
(from `gitea_whoami`) against the **PR author**.
|
||
- If they match, `pr.approve` and `pr.merge` fail closed, regardless of what the
|
||
profile's capability booleans say.
|
||
- This is why author and merger/reviewer roles are separated by **identity**,
|
||
not by prompt or by a single escalating profile. It is also why this was the
|
||
concrete blocker discovered while dogfooding PR #8 for issue #52.
|
||
|
||
## Token and secret handling
|
||
|
||
- Token **values** are never logged, never returned by any tool, and never
|
||
committed to the repository.
|
||
- Profiles reference a `token_source_name` (a variable/key *name*) only.
|
||
- `Authorization` headers and raw credentials must never appear in tool output,
|
||
audit records, or error messages.
|
||
|
||
## Separation from other MCP boundaries
|
||
|
||
`gitea-mcp` profile work stays within the Gitea trust boundary. It must **not**
|
||
add or absorb Jenkins, Ops, GlitchTip, Release, deploy, rollback, migration,
|
||
restart, or production behavior. Those belong to their own MCP packages under
|
||
the "one server per trust boundary" model described in
|
||
[`tool-boundaries.md`](tool-boundaries.md) and
|
||
[`credential-isolation.md`](credential-isolation.md).
|
||
|
||
## Profile Activation and Runtime Identity Clarity (#131)
|
||
|
||
To make Gitea MCP profile activation and runtime identity state explicit, the following mechanisms are supported:
|
||
|
||
### 1. Static-Profile vs. Dynamic-Profile Mode
|
||
- **Static-Profile Mode (Default):** The active profile is fixed at server launch based on the `GITEA_MCP_PROFILE` environment variable (with `GITEA_MCP_CONFIG` pointing to the config path). Local environment variables are static once a subprocess is spawned by the host. Modifying the environment variables on the host does not dynamically update an already-connected MCP server process.
|
||
- **Dynamic-Profile Mode:** Profile switching via the `gitea_activate_profile` tool is supported **only** if the configuration JSON explicitly opts in by setting `"allow_runtime_switching": true` under rules or top-level keys. Otherwise, attempting to switch profiles dynamically will fail closed.
|
||
|
||
### 2. Dual MCP Namespaces Recommendation
|
||
For security-sensitive or high-risk tasks, the preferred safety model uses separate, isolated MCP server instances (namespaces/sessions) launched with static profiles:
|
||
- `gitea-author`: Exposes tools configured with author permissions; cannot perform approvals or merges.
|
||
- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews. Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||
- `gitea-merger`: Exposes tools configured with merger permissions; used for PR merges.
|
||
This layout maintains physical separation of credentials and prevents privilege escalation within a single session.
|
||
This is the model accepted in #139; deployment details, rationale, and client
|
||
setup live in
|
||
[`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md).
|
||
|
||
### 3. Verification Post-Switching
|
||
When dynamic profile switching is enabled and a profile is activated via `gitea_activate_profile`, the session MUST immediately:
|
||
1. Clear the cached identity.
|
||
2. Call `gitea_whoami` with the target remote to prove and verify the fresh Gitea authenticated identity.
|
||
This guarantees the active profile operations align with the actual Gitea authenticated user credential.
|
||
|
||
## Gitea MCP Runtime Isolation and Worktree Safety
|
||
|
||
To ensure high availability and prevent broken feature worktrees from disabling essential security/identity controls, the Gitea MCP server implements runtime isolation:
|
||
|
||
- **Startup Conflict Check:** The MCP server (`mcp_server.py`) acts as a conflict-free loader. On startup, it scans all Python files in the directory for unresolved git merge conflicts (`<<<<<<<`, `=======`, `>>>>>>>`). If any are found, it prints an `infra_stop` message and exits immediately.
|
||
- **Workflow Guard:** Before starting reviewer work, task routing checks if the MCP runtime source is mid-merge (by checking for `.git/MERGE_HEAD` or conflict markers). If dirty, it returns `infra_stop` (never `wrong_role_stop` or empty queue) to prevent unsafe mutations.
|
||
- **Recovery Instructions:** To recover from an `infra_stop` state:
|
||
1. Resolve all merge conflicts in the local repository or abort the merge (`git merge --abort` / `git rebase --abort`).
|
||
2. Restart the Gitea MCP server process.
|
||
3. Retry the task.
|
||
|
||
## Relationship to roadmap issues
|
||
|
||
This document defines the **model only**. Related work is tracked separately
|
||
under roadmap [#10](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/10):
|
||
|
||
- **#11** — Authenticated-user identity lookup (`gitea_whoami`). *Complete;
|
||
this model depends on it for verified identity.*
|
||
- **#19** — Runtime profile configuration via environment (loading real
|
||
profiles/tokens). *Not this issue.*
|
||
- **#13** — Read-only profile discovery (exposing the active profile). *Not this
|
||
issue.*
|
||
- **#14** — PR author / reviewer eligibility checks. *Not this issue.*
|
||
- **#15** — Gated PR review/approve actions. *Not this issue.*
|
||
- **#16** — Gated PR merge workflow. *Not this issue.*
|
||
- **#18** — Audit logging of mutating actions with profile metadata. *Not this
|
||
issue.*
|
||
|
||
## Non-goals
|
||
|
||
- Do **not** implement runtime profile switching or selection here.
|
||
- Do **not** implement multi-token loading here.
|
||
- Do **not** implement approve, merge, or eligibility workflows here.
|
||
- Do **not** expose, log, or commit any token or secret.
|
||
- Do **not** add Jenkins, Ops, GlitchTip, Release, deploy, or production
|
||
behavior.
|
||
- Do **not** create an all-powerful server; `gitea-owner` is administrative, not
|
||
a default automation role.
|