The independent review reproduced a real hole: the production first-bind path never pinned repository/org, so the drift checks it feeds were skipped. Root cause ---------- gitea_whoami, gitea_get_runtime_context, gitea_resolve_task_capability and gitea_activate_profile all seeded the session context without repository or org. First-bind-wins then made the mutation gate's later seed a no-op, and assess_session_context only compares repository/org when the bound fields are non-null. Result, reproduced in production order with the real REMOTES: after whoami: repository=None org=None same-host repo=Other-Tools -> NOT BLOCKED same-host org=Other-Org -> NOT BLOCKED cross-host -> blocked (this part always worked) Binding REMOTES defaults would not fix it: REMOTES['prgs'].repo is 'Timesheet' (a default *target*, not an authorization scope, see #530), so pinning it fails closed on every legitimate Gitea-Tools mutation. There was no trusted source for repository scope, so this adds one. Design ------ - New optional profile field `allowed_repositories`: canonical owner/repository slugs. An authorization boundary, never the binding itself. Config-only — no env var can widen or forge it. - The session repository is derived from the verified, workspace-aligned git remote, never from caller-supplied org/repo, and validated against that allowlist. The session binds immutably to exactly one canonical slug; org is derived from it. A profile authorizing several repositories still binds to the single verified one. - Every first-bind entry point now pins the same complete context. Activation rejects an unauthorized/unverifiable workspace. Mutations fail closed when repository/org is unverified, and a tool-level org/repo override that disagrees with the binding is rejected before the write. - A mutation request can no longer establish, complete, or replace the binding (it previously seeded from `org or REMOTES[...]`, i.e. caller-controlled). - Enforcement is opt-in per profile: a profile without the field keeps prior behaviour, so existing static-profile namespaces stay functional. First-bind-wins, immutability, the RLock, profile isolation, host/identity validation and the private pytest-only reset are unchanged. gitea_auth.get_profile() built an explicit whitelist dict and silently dropped `allowed_repositories`; the new integration tests caught that the scope was never enforced through the real path. Tests ----- New tests/test_issue_714_production_first_bind.py drives the real entry points in production order rather than constructing _SessionContext directly. Covers complete first bind via each entry point, same-host repo/org drift, Timesheet and other-repo/other-org rejection, unverified and unauthorized workspaces, caller values unable to establish the binding, concurrent init selecting one repository, and the PR #715 author path staying functional. Mutation-verified: disabling trusted derivation, override validation, the scope check, or the get_profile passthrough each fails these tests (9/8/4/5 failures). No security assertion was weakened, removed, skipped, or rewritten. Focused: 26 passed. Issue #714 total: 46 passed. Prior failing modules: 240 passed. Config/profile/remote modules: 130 passed. Full suite: 2739 passed, 6 skipped, 1 pre-existing warning, 161 subtests in 25.80s. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
19 KiB
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.
Principle: LLMs are not roles
The central rule of this model:
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. |
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:
"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:
- The session repository is derived from the verified, workspace-aligned git
remote — never from a caller-supplied
org/repoargument, and never from theREMOTEStable (whose entries are default targets:prgsdefaults toTimesheet, which is not this project). - That workspace-derived slug is validated against
allowed_repositories. - 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. - 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/repooverride 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:falsecan_merge_prs:falsecan_push_branches:falsecan_mutate_issues:truecan_author_impl_prs:false
gitea-author
- allowed:
read,branch.push,pr.create,pr.comment,issue.comment - forbidden:
pr.approve,pr.merge can_approve_prs:falsecan_merge_prs:falsecan_push_branches:truecan_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:truecan_merge_prs:falsecan_push_branches:falsecan_mutate_issues:falsecan_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:truecan_push_branches:falsecan_mutate_issues:falsecan_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:truecan_merge_prs:truecan_push_branches:truecan_mutate_issues:truecan_author_impl_prs:true
gitea-ownerexists 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_operationsalways wins overallowed_operations. If an operation appears in both, it is forbidden.- An operation not present in
allowed_operationsis 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.readnever matches a Gitea check, and a Gitea alias is never applied to another service). forbidden_operationsoverridesallowed_operationsafter 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_operationslist 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 requiresgitea.read; creating requiresgitea.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 thegitea.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_prwithtitle/body/base, or reopening withstate="open") stay on the ordinary edit path and need no dedicated capability. - PR closure (
gitea_edit_prwithstate="closed") requires the distinctgitea.pr.closeoperation. The resolver task isclose_pr(gitea_resolve_task_capability(task="close_pr"), author-side). Withoutgitea.pr.closethe close attempt fails closed — no API call, structuredpermission_report— so the broad edit path can never be used as an untracked close fallback. - Closures are audited as a distinct
close_praction withrequired_permission: gitea.pr.closein 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.readgitea.pr.commentgitea.issue.commentgitea.issue.closegitea.pr.close
Forbidden on reconciler profiles: gitea.pr.approve, gitea.pr.merge,
gitea.pr.review, gitea.pr.create, gitea.branch.push, and
gitea.repo.commit.
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.
Identity and fail-closed rules
Before any mutating action, a workflow must know both:
- The active profile — which profile is in effect for this task.
- 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 inforbidden_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(fromgitea_whoami) against the PR author. - If they match,
pr.approveandpr.mergefail 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. Authorizationheaders 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 and
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_PROFILEenvironment variable (withGITEA_MCP_CONFIGpointing 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_profiletool is supported only if the configuration JSON explicitly opts in by setting"allow_runtime_switching": trueunder 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 ingitea-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:
- Clear the cached identity.
- Call
gitea_whoamiwith 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 aninfra_stopmessage 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_HEADor conflict markers). If dirty, it returnsinfra_stop(neverwrong_role_stopor empty queue) to prevent unsafe mutations. - Recovery Instructions: To recover from an
infra_stopstate:- Resolve all merge conflicts in the local repository or abort the merge (
git merge --abort/git rebase --abort). - Restart the Gitea MCP server process.
- Retry the task.
- Resolve all merge conflicts in the local repository or abort the merge (
Relationship to roadmap issues
This document defines the model only. Related work is tracked separately under roadmap #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-owneris administrative, not a default automation role.