sysadminandClaude Opus 4.8 b3de9c941c docs(remote-mcp): threat model, trust boundaries, and decomposition ruling (Closes #956)
#930 inventoried stdio coupling; nothing stated what the adversary is, what
each boundary protects, or why one process may hold credentials for several
services. This adds that document as child 2 of epic #929.

Adds docs/remote-mcp/threat-model.md covering 10 assets, the 5 adversaries
#956 names, 9 trust boundaries, the data flows between them, a 14-entry
per-boundary credential inventory, and an explicit decomposition ruling.

Findings established from live native evidence at this commit:

- Four prgs roles (reviewer, merger, reconciler, controller) resolve to one
  Gitea account, so separation of duty between approving and landing is
  enforced only by which process a call reaches. The mdcps tenant has no role
  separation at all: author, reviewer, and merger share one account.
- Any one role process can resolve every other role's credential.
  gitea_list_profiles reports "credentials present" for other roles because it
  calls resolve_token on each one.
- The Gitea server reads Jenkins and GlitchTip secrets out of the keychain to
  produce the "authenticated" word in gitea_audit_config's service summaries.
- Jenkins and GlitchTip were already decomposed into separate MCP servers; the
  credential references were left behind in the Gitea configuration.

Ruling D1 forbids a single integration process from holding credentials for
unrelated services, with one time-boxed dual-run exception for the local fleet
that expires with #939. D2 requires separation of duty to be credential-backed,
D3 scopes credential resolution to the request principal, and D4 gives
coordination state its own authority. Every #929 child from 2 through 10 is
mapped to the boundary it implements.

Anchors are enforced rather than asserted. #930's inventory anchors into
gitea_mcp_server.py had already drifted between 7bf4f125 and aad5c8b4 with
nothing detecting it, so this change ships the guard that was missing:
docs/remote-mcp/threat-model-anchors.json declares all 58 anchors with the
substring each must contain, and tests/test_issue_956_threat_model.py fails if
any anchor does not resolve, if the document cites an anchor the fixture does
not cover, or if the structural obligations regress.

Documentation only. No server behavior changes.

Tests:
- tests/test_issue_956_threat_model.py: 17 passed.
- Four sabotage probes confirm the validator is not passing vacuously
  (shifted anchor, undeclared citation, broken count tally, and a blanked
  boundary owner). The last two probes exposed real weaknesses in the checks
  themselves, which were fixed: the section slice now stops at the next
  heading, and boundary ownership is read only from mapping table rows.
- Docs-sensitive sweep (17 modules referencing docs/): 559 passed.
- Full suite: 30 failed, 5799 passed, 6 skipped. All 30 reproduce on a clean
  base worktree at aad5c8b4; branch failures are a strict subset of base
  failures. No production file is modified by this change.

Closes #956

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 04:41:26 -04:00

Gitea Tools

A collection of Python scripts and an MCP server to automate interactions with Gitea instances.

Supported Instances

Remote Host Org / Repo
dadeschools gitea.dadeschools.net Contractor / Timesheet
prgs gitea.prgs.cc Scaled-Tech-Consulting / Timesheet

Authentication

Authentication is configured via environment variables or a local .env file in the repository root (uses python-dotenv).

Create a .env file in the project root:

# Option A: Gitea Personal Access Tokens (Recommended)
GITEA_TOKEN_DADESCHOOLS="your_token_here"
GITEA_TOKEN_PRGS="your_token_here"

# Option B: Gitea Username & Password (fallback)
GITEA_USER_DADESCHOOLS="username"
GITEA_PASS_DADESCHOOLS="password"
GITEA_USER_PRGS="username"
GITEA_PASS_PRGS="password"

# Optional: Fallback to macOS Keychain (via git credential fill)
# GITEA_USE_KEYCHAIN=1

The Gitea-Tools MCP server exposes all functionality as structured tool calls. Any MCP-compatible agent (Antigravity, Claude Code, etc.) can call these tools natively.

Available Tools

Tool Description
gitea_create_issue Create an issue with title, body, remote
gitea_create_pr Open a pull request with title, head, base
gitea_edit_pr Edit details of an existing pull request
gitea_list_prs List pull requests with state/remote
gitea_view_pr Get full details of a single pull request
gitea_merge_pr Gated merge: merge/squash/rebase only after identity+profile+eligibility gates pass, explicit confirmation="MERGE PR <n>", optional head-SHA and changed-files pinning (no self-merge, no force)
gitea_review_pr Legacy wrapper for gitea_submit_pr_review (merging disabled)
gitea_delete_branch Delete a remote branch
gitea_close_issue Close an issue by number
gitea_list_issues List issues with state/label filters
gitea_view_issue Get full details of a single issue
gitea_whoami Read-only: identify the authenticated Gitea account (safe metadata only)
gitea_get_profile Read-only: describe the active runtime execution profile (safe metadata only)
gitea_check_pr_eligibility Read-only: check if the current identity/profile may review/approve/request_changes/merge a PR
gitea_assess_conflict_fix_classification Read-only: classify conflict-fix need from a live PR head re-fetch before creating a conflict-fix worktree
gitea_submit_pr_review Gated review mutation: comment/approve/request_changes, only after identity+profile+eligibility gates pass (no merge, no self-approval)
gitea_mark_issue Claim/release an issue (start/done)
gitea_list_labels List all available labels in a repository
gitea_create_label Create a new label with custom color
gitea_set_issue_labels Replace all labels on an issue
gitea_get_file Retrieve file content and SHA metadata
gitea_commit_files Commit changes to multiple files atomically
gitea_mirror_refs Mirror branches + tags between instances

Setup

1. Install dependencies

cd /Users/jasonwalker/Development/Gitea-Tools
python3 -m venv venv          # skip if venv already exists
source venv/bin/activate
pip install "mcp[cli]"

2. Configure your AI client

The MCP server uses stdio transport — each client starts it as a subprocess. Add the config below to your client, then restart it.

Antigravity (Google)

Add to ~/.gemini/antigravity-ide/mcp_config.json inside "mcpServers":

"gitea-tools": {
  "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
  "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
  "env": {}
}

Restart Antigravity to load the server. Tools appear as lazy-loaded MCP tools (call via call_mcp_tool with ServerName: "gitea-tools").

Claude Code (Anthropic)

Add to ~/.claude.json (global) or .mcp.json in the project root:

{
  "mcpServers": {
    "gitea-tools": {
      "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
      "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"]
    }
  }
}

Restart Claude Code. Tools appear as mcp__gitea-tools__gitea_create_issue, etc.

Any MCP-compatible client

The server is a standard MCP stdio server. Point your client at:

  • Command: /Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3
  • Args: ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"]
  • Transport: stdio

No environment variables needed — auth is handled via macOS keychain.

Runtime profiles (multiple env-configured entries)

The same server can run as separate MCP entries, each authenticating as its own Gitea token and carrying its own profile name. This keeps roles task-scoped: the profile is the role, not the LLM. Point each entry at a different gitignored env file.

{
  "mcpServers": {
    "gitea-tools-reviewer": {
      "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
      "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
      "env": {
        "GITEA_PROFILE_NAME": "gitea-reviewer",
        "GITEA_ALLOWED_OPERATIONS": "read,review,approve"
      }
    },
    "gitea-tools-merger": {
      "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
      "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
      "env": {
        "GITEA_PROFILE_NAME": "gitea-merger",
        "GITEA_ALLOWED_OPERATIONS": "read,merge"
      }
    }
  }
}

Recognized environment fields (see .env.example for placeholders):

Variable Purpose
GITEA_TOKEN API token for this runtime. Read only by the auth layer; never returned, logged, or committed.
GITEA_PROFILE_NAME Non-secret label for the running profile (e.g. gitea-reviewer). Surfaced by gitea_whoami.
GITEA_ALLOWED_OPERATIONS Optional, comma-separated operation categories (descriptive metadata only for now).
GITEA_FORBIDDEN_OPERATIONS Optional, comma-separated categories this profile must not perform (descriptive).
GITEA_AUDIT_LABEL Optional short label for this runtime, for audit purposes.
GITEA_TOKEN_SOURCE Optional name of the token source (e.g. an env var name). A name only — never the token value.
GITEA_BASE_URL Optional informational base URL.
GITEA_AUDIT_LOG Optional path to an audit log file. When set, mutating actions append one redacted JSON record each (profile + authenticated user + outcome). Unset ⇒ auditing off (no records, no extra API calls).
GITEA_MCP_CONFIG Optional path to a JSON file defining multiple named runtime profiles. Unset ⇒ pure env behaviour.
GITEA_MCP_PROFILE Name of the profile (from GITEA_MCP_CONFIG) to activate for this runtime.

External MCP Control Plane servers

Jenkins and GlitchTip are separate MCP trust boundaries, not tools inside this Gitea MCP runtime. Register them as jenkins-mcp and glitchtip-mcp in the client that will use them, then reconnect or reload the client and verify the expected tools are visible before claiming readiness. See docs/mcp-client-registration.md.

Notes:

  • This provides one token + one profile per process. It does not implement multi-token switching inside a single runtime, nor any approve/merge/eligibility gating — those are later roadmap items (#14#18).
  • Profile name and allowed operations are metadata only; the token value is never part of any tool output. gitea_whoami returns the profile name, and gitea_get_profile returns the full non-secret profile metadata so a workflow can inspect which runtime it is talking to before deciding to act.
  • See docs/gitea-execution-profiles.md for the full profile model, and docs/llm-workflow-runbooks.md for the task-scoped, profile-based runbooks (create/review/merge/close, thin launchers, migration, fail-closed rules).
  • For the portable version of this workflow (issue-first, isolated worktrees, no self-review/merge, profile safety, cleanup, fail-closed) that can be copied into any project, see the reusable skill skills/llm-project-workflow/SKILL.md.
  • Audit logging (#18): mutating actions emit a durable, redacted JSON audit record — timestamp, action, result (allowed/blocked/failed/succeeded), profile name + audit label, authenticated username, target repo/issue/PR, branch and head SHA where applicable — when GITEA_AUDIT_LOG is set. Auditing is off by default and never adds API calls or breaks the action when off. See gitea_audit.py.

Canonical runtime profiles (#19). Define every Gitea profile once, in a canonical JSON file, and keep each LLM launcher (Claude / Gemini / Codex) a thin pointer at it — no duplicated GITEA_USER_* / GITEA_PASS_* blocks and no raw tokens in client configs. See gitea-mcp.example.json, loaded by gitea_config.py.

Canonical profile file (e.g. ~/.config/gitea-tools/profiles.json):

{
  "version": 1,
  "profiles": {
    "prgs": {
      "base_url": "https://gitea.prgs.cc",
      "username": "jcwalker3",
      "auth": { "type": "keychain", "id": "prgs-gitea-token" },
      "default_owner": "Scaled-Tech-Consulting",
      "execution_profile": "personal-prgs"
    },
    "mdcps": {
      "base_url": "https://gitea.dadeschools.net",
      "username": "913443",
      "auth": { "type": "env", "name": "GITEA_TOKEN_MDCPS" },
      "execution_profile": "mdcps"
    },
    "mdcps-reviewer": {
      "base_url": "https://gitea.dadeschools.net",
      "username": "913443",
      "auth": { "type": "keychain", "id": "mdcps.gitea.reviewer.token" },
      "execution_profile": "mdcps-reviewer"
    }
  }
}

Thin LLM launcher (Claude / Gemini / Codex) — only two env vars, no secrets:

"gitea-tools": {
  "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
  "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
  "env": {
    "GITEA_MCP_CONFIG": "/Users/jasonwalker/.config/gitea-tools/profiles.json",
    "GITEA_MCP_PROFILE": "prgs"
  }
}
  • Secrets by reference only: a profile's auth names where the token lives — { "type": "keychain", "id": "..." } (macOS keychain) or { "type": "env", "name": "..." } (env var). Inline token/password keys are rejected. The value is resolved on demand and never stored in, returned by, or logged as profile metadata.
  • Precedence: explicit process env vars (GITEA_PROFILE_NAME, GITEA_BASE_URL, GITEA_TOKEN, …) override the JSON profile; the JSON profile only fills what the environment leaves unset.
  • Backwards compatible / fail-safe: with GITEA_MCP_CONFIG unset, behaviour is exactly the legacy env-only mode. A missing file, invalid JSON, unsupported version, unknown/unset selected profile, or unresolvable secret reference raises a clear startup error that never prints file contents, tokens, or passwords. Parsing makes no network calls.

Migrating from duplicated GITEA_PASS_* blocks. Move each instance's credentials into one canonical profile entry (referencing a keychain id or env var for the secret), then delete the GITEA_USER_* / GITEA_PASS_* / GITEA_SITE_* blocks from every LLM mcp_config.json, leaving only GITEA_MCP_CONFIG + GITEA_MCP_PROFILE. Existing env-only setups keep working unchanged until migrated.

Interactive setup — no hand-editing JSON. Run the menu to create/edit/ validate profiles, store a token in the macOS keychain (never echoed or written to any config), test a profile's authentication, print the authenticated user, check reviewer eligibility for a PR, and generate ready-to-paste launcher snippets for Claude / Gemini / Codex:

./scripts/gitea-config-menu

The generated launcher snippets contain only command, args, GITEA_MCP_CONFIG, and GITEA_MCP_PROFILE — never a token or password.

Portable LLM workflow skill

Reusable LLM operating rules are packaged as a portable skill at skills/llm-project-workflow/SKILL.md. It documents issue-first work, isolated branch worktrees, no self-review or self-merge, profile safety, fail-closed behavior, merge cleanup, and recovery patterns. Copy the skills/llm-project-workflow/ directory into other projects that should use the same workflow.

Codex / non-MCP tools

OpenAI Codex and other tools that don't support MCP can use the CLI scripts directly. See the CLI Scripts section below.

# Example: Codex can shell out to the scripts
python3 /Users/jasonwalker/Development/Gitea-Tools/create_issue.py \
  --remote prgs --title "Bug report" --body "Details here"

CLI Scripts

The MCP tools can also be used as standalone CLI scripts:

Script Description
create_issue.py Create an issue (--remote, --title, --body, --body-file)
create_pr.py Open a Pull Request (--remote, --title, --head, --base)
edit_pr.py Edit a Pull Request (--title, --body, --body-file, etc.)
review_pr.py Review/sign-off on a pull request (--merge is disabled — fails closed; merge only via gated gitea_merge_pr)
close_issue.py Close a specific issue
mark_issue.py Claim/release an issue via status:in-progress label
manage_labels.py Create label set and apply label mappings (--dry to preview)
mirror_refs.sh Mirror branches + tags between dadeschools ⇄ prgs

Quick Examples

# Create an issue
./create_issue.py --title "Fix PDF output" --body "Blank on Safari"

# Create an issue on the prgs instance
./create_issue.py --remote prgs --title "Add tests" --body-file description.md

# Create a PR
./create_pr.py --title "feat: add validation" --head feat/validation --body "Closes #12"

# Edit a PR's description or title
./edit_pr.py 155 --body "Updated description wording"

# Review and approve a PR (review only — CLI merge is disabled; use the
# gated gitea_merge_pr MCP workflow to merge)
./review_pr.py --pr-number 12 --event APPROVE --body "Approved"

# Close issue #5
./close_issue.py 5

# Claim an issue before working on it
./mark_issue.py 10 start

# Release when done
./mark_issue.py 10 done

# Mirror refs (dry-run by default)
./mirror_refs.sh

# Actually push the refs
./mirror_refs.sh --apply

Use --help on any Python script or shell script for full usage details.

Architecture

gitea_auth.py    ← shared auth & API helpers (get_credentials, api_request)
mcp_server.py    ← MCP server (FastMCP, stdio transport)
create_issue.py  ← CLI: create issues
create_pr.py     ← CLI: create PRs
edit_pr.py       ← CLI: edit PRs
review_pr.py     ← CLI: review PRs
manage_labels.py ← CLI: label management
close_issue.py   ← CLI: close issues
mark_issue.py    ← CLI: claim/release issues
mirror_refs.sh   ← CLI: ref mirroring

Tests

# Run with the venv (includes MCP SDK)
source venv/bin/activate
python3 -m pytest tests/ -v
Test file Covers
test_mcp_server.py All 7 MCP tools: create, list, view, close, mark, PR, mirror
test_create_issue.py CLI arg parsing, remote resolution, payload, auth, errors
test_create_pr.py CLI arg parsing, remote resolution, payload, auth, errors
test_credentials.py get_credentials(), get_auth_header(), repo_api_url()
test_manage_labels.py Label create/skip, dry run, mapping, constant validation
test_python_cli.py close_issue.py + mark_issue.py CLI validation
test_mirror_refs.py Flags, safety defaults, local integration tests

(Core suites — the table is non-exhaustive; see tests/ for the full set.)

All tests mock network and keychain access — no real API calls are made.

For how to write tests — mocking the API/auth safely, testing profile and self-review/self-merge gates, no-secret regression expectations, and unit vs. integration guidance — see docs/developer-testing-guidelines.md.

Troubleshooting

macOS: com.apple.provenance blocks Python execution (#3)

On macOS Sequoia and later, files written by an agent/IDE terminal receive the com.apple.provenance extended attribute, and macOS blocks Python.app from executing such files. Symptoms: newly created/restored .py files fail to run (e.g. create_issue.py "vanishing" or refusing to execute), while shell scripts and files created before the session are unaffected. This is a macOS security feature, not a bug in this project's code.

Workarounds (run from a terminal with Full Disk Access, e.g. Terminal.app — not the IDE terminal, or the removal itself may be blocked):

# Preferred: strip only com.apple.provenance under the repo (dry-run first)
./scripts/clear-provenance --dry-run
./scripts/clear-provenance

# Or a single file
./scripts/clear-provenance /path/to/file.py

# Manual equivalents
xattr -r -d com.apple.provenance /Users/jasonwalker/Development/Gitea-Tools/
xattr -cr /Users/jasonwalker/Development/Gitea-Tools/   # clears ALL xattrs

Alternatively, grant Full Disk Access to the terminal app in System Settings → Privacy & Security. scripts/clear-provenance removes only com.apple.provenance (leaving other extended attributes intact) and supports --dry-run.

S
Description
Python and Bash scripts to automate interactions with Gitea instances (issues, PRs, labels).
Readme
55 MiB
Languages
Python 99.3%
Shell 0.7%