Files
Gitea-Tools/docs/gitea-execution-profiles.md
T
sysadmin 4a63578003 fix(profiles): canonical reconciler ops and guard raw branch delete (#687)
Address REQUEST_CHANGES on PR #688:

- migrate_profiles: emit fully canonical reconciler/author/reviewer defaults;
  canonicalize explicit ops; fail if reconciler loses required pr.close/read
- gitea_delete_branch: deny reconciler (and non-author roles); refuse
  preservation/protected branches before any API call
- gitea_cleanup_merged_pr_branch: require reconciler role only; block
  preservation/evidence branches via branch_cleanup_guard
- docs: end-to-end operator runbook for profile migrate/apply/reconnect/cleanup
- tests: migration canonicalization, idempotency, raw-delete denial, guarded
  cleanup success and unmerged/preserve rejections

Closes #687.
2026-07-12 22:35:14 -04:00

25 KiB
Raw Blame History

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.
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.

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 (readjenkins.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).

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):

    # 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 identitys 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:

    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:

    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_paritystale=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):

    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:

{
  "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_operationsstop; 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 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_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.

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:

  • #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.