Addresses B1 and B2 from formal review 635 on PR #966. Both are one defect:
the seam conflated two questions that must stay separate.
recognition: "is this an identifier this system knows, and may it be
bound, pinned and recorded?"
execution: "is this entrypoint commissioned to actually serve on it?"
SUPPORTED_TRANSPORTS answered the first and was then used to answer the
second, so registering streamable-http was the same act as executing it.
With mcp.run fed the bound value, GITEA_MCP_TRANSPORT=streamable-http
reached FastMCP.run -> run_streamable_http_async and started a uvicorn
HTTP server over the whole tool surface with auth=None, no TLS and no
per-request principal. That endpoint is owned by #938 and is an explicit
non-goal of #931. B2 is why B1 was reachable: bound_transport was read
only by reporting sites, so no decision anywhere depended on it.
The fix adds an execution-authorization layer rather than removing the
identifier or mapping it to stdio, both of which would have destroyed the
seam #931 exists to create.
mcp_transport_config gains EXECUTABLE_TRANSPORTS, a strict subset of
SUPPORTED_TRANSPORTS containing only the local transport, plus
TRANSPORT_EXECUTION_OWNER recording that #938 commissions the remote
listener. assess_transport_execution returns a structured verdict:
transport, recognized, executable, allowed, blocker_kind, owner_issue,
reasons and exact_next_action.
mcp_daemon_guard gains assess_serve_authorization, which is the decision
that consumes bound_transport and is what stops it being reporting-only
metadata, and authorize_transport_execution, which enforces two ordered
boundaries: assert_transport_bound first, so an unbound runtime keeps its
pre-existing #695 failure and reason code; then execution authorization,
so a registered but uncommissioned transport is refused before any
listener exists and before any tool can dispatch. TransportExecutionError
subclasses UnsanctionedRuntimeError, so every existing fail-closed handler
still catches it while a caller that cares can distinguish the two cases.
native_runtime_status now reports executable_transports, serve_authorized
and the full serve_authorization verdict.
The entrypoint serves through authorize_transport_execution.
Net effect. stdio: unchanged, still binds and still reaches the runner.
streamable-http: still recognized, still validated, still pinned, still
recorded in decision-lock and audit provenance -- and refused at the serve
boundary with blocker transport_listener_not_commissioned naming #938.
Unregistered identifiers still fail earlier, at bind validation. Unbound
execution keeps the #695 contract. Rebind idempotence and conflict
rejection, session isolation, provenance and secret handling are untouched.
Tests: 63 in tests/test_issue_931_transport_bind_seam.py, up from 42. New
coverage proves stdio reaches the real runner; the remote identifier binds
and is recorded yet never reaches run(); no socket is bound during the
refusal, asserted with a socket.bind tripwire; the refusal names the
transport, the unmet requirement and #938; the decision genuinely consumes
bound_transport, proven by flipping only that value; no mutation is
authorized after denial; verdicts do not leak across runtimes; and the
refusal carries no credential material. Per review 635 the two file-list
scans are now globs over the production surface, and a new test asserts
there is exactly one mcp.run site and that it routes through the guard.
#956 anchors re-anchored for the two lines this change shifted,
mcp_daemon_guard.py 178->195 and 519->583. No other anchor moved.
Full suite from the branches/ worktree: 28 failed, 5864 passed, 6 skipped,
1047 subtests. Base 9b80e75c: 28 failed, 5801 passed, 6 skipped, 1047
subtests. Failing test IDs are identical sets in both directions; the +63
passed are this module.
No listener, socket, authentication, TLS or principal code is added. #938,
#957, #958 and #959 remain unimplemented.
Refs #931, #938. Addresses review 635.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
408 lines
34 KiB
Markdown
408 lines
34 KiB
Markdown
# Remote-MCP threat model, trust boundaries, and service decomposition
|
||
|
||
What the adversary is, what each boundary protects, and which services may share a process.
|
||
|
||
- **Issue:** #956 (Remote-MCP threat model), child of epic #929, cross-linked to #955.
|
||
- **Depends on:** #930 (closed) — `docs/remote-mcp/coupling-inventory.md`.
|
||
- **Blocks:** #932, #933, #934, #938.
|
||
- **Generated against commit:** `c1626081757dff76ea49fcb1869fc0362dea45b1` (#931's transport
|
||
bind seam). Originally generated against `aad5c8b42361d380a8eeb07b94b90815e594c2c5`
|
||
(`master`) and re-anchored when #931 shifted the cited lines.
|
||
- **Scope:** documentation only. This child changes no server behavior. It adds one
|
||
document, one anchor fixture, and the test that enforces them.
|
||
|
||
## Relationship to #930
|
||
|
||
#930 asked *what breaks when the process stops being local*. This document asks *what an
|
||
attacker gets, and where we stop them*. The two are deliberately different axes: #930
|
||
classifies each coupling as portable, seam, replacement, or cannot-be-remote; this document
|
||
classifies each **credential** by blast radius and each **boundary** by what crossing it
|
||
requires. An entry can be perfectly portable and still be a trust disaster —
|
||
`gitea_config.py:851` is portable Python that reads a CI secret from inside the Gitea server.
|
||
|
||
### Anchors are enforced, not asserted
|
||
|
||
Every `file:line` in this document is declared in `docs/remote-mcp/threat-model-anchors.json`
|
||
with the substring that must appear at that line, and
|
||
`tests/test_issue_956_threat_model.py` fails if any anchor does not resolve or if the
|
||
document cites an anchor the fixture does not cover.
|
||
|
||
This guard exists because #930 did not have one. Its inventory was generated at
|
||
`7bf4f125`; by `aad5c8b4` its `gitea_mcp_server.py` anchors had drifted — the transport
|
||
bind it cited at line 23750 now lives at `gitea_mcp_server.py:24728`, and its
|
||
client-managed provenance anchor at 14588 now lands in an unrelated function. Nothing
|
||
failed, because nothing checked. Anchors into a ~24,700-line module rot silently, and a
|
||
security document that cannot prove its own citations is worse than none, because it is
|
||
trusted.
|
||
|
||
---
|
||
|
||
## 1. Assets
|
||
|
||
What an adversary wants. Ordered by consequence, not by likelihood.
|
||
|
||
| ID | Asset | Why it matters |
|
||
| -- | ----- | -------------- |
|
||
| A1 | Merge authority on `Scaled-Tech-Consulting/Gitea-Tools` | This repository *is* the control plane. Code merged here becomes the gate that authorizes every future mutation, so merge authority is self-amplifying: one merge can disable every other control in this document. |
|
||
| A2 | Write authority on the `mdcps` tenant | A second, unrelated organization reachable from the same configuration. Compromise here is a cross-organization incident, not an internal one. |
|
||
| A3 | The eight Gitea role credentials | Long-lived bearer tokens. Possession is authority; there is no second factor at the API. |
|
||
| A4 | Jenkins read access (`mdcps`, enabled) | Build logs routinely carry deployment topology, internal hostnames, and accidentally-echoed secrets. |
|
||
| A5 | Error-tracking read access (GlitchTip / Sentry) | Event payloads carry stack frames, request context, and production user data. |
|
||
| A6 | Coordination-state integrity | The locks, leases, and review-decision records that make "exactly one owner" true. Corrupting them needs no Gitea credential and produces duplicate or lost work. |
|
||
| A7 | The operator's checkout and worktrees | Unmerged code, branch state, and the filesystem the author tools write to. |
|
||
| A8 | The macOS login keychain | The meta-credential. Everything in A3, A4, and A5 resolves from it. |
|
||
| A9 | Separation of duty between review and merge | The property that no single actor both approves and lands a change. An *asset*, not a control, because it is what the controls exist to produce. |
|
||
| A10 | Audit and provenance records | Determine whether an incident is reconstructable. An attacker who can forge provenance makes an intrusion indistinguishable from normal work. |
|
||
|
||
## 2. Adversaries
|
||
|
||
| ID | Adversary | Capability assumed | Not assumed |
|
||
| -- | --------- | ------------------ | ----------- |
|
||
| ADV1 | **Compromised LLM client** | Full control of one MCP client. Issues arbitrary tool calls, in any order, with any arguments, at machine speed. Sees every tool result. | Cannot read the operator's disk except through tools; cannot execute arbitrary local code outside the tool surface. |
|
||
| ADV2 | **Prompt injection** via repository content | Controls text the model reads and treats as instruction — issue bodies, PR descriptions, review comments, commit messages, file contents. Reaches the model on any read of untrusted content. | Holds no credential and issues no call directly. Its entire power is causing an *authorized* client to act. |
|
||
| ADV3 | **Malicious tool arguments** | Supplies hostile values to any parameter — paths, branch names, session identifiers, worktree paths, issue numbers — including traversal, injection, and confusion between look-alike identifiers. | Cannot bypass a gate that actually validates its input. |
|
||
| ADV4 | **Network attacker** | Observes and modifies traffic between client, server, and Gitea. Attempts downgrade, replay, and endpoint impersonation. | Does not hold a valid credential at the start. |
|
||
| ADV5 | **Curious operator** | Legitimate local access to the workstation: process table, `/tmp`, home directory, keychain prompts. Not malicious, but not authorized for every role either. | Does not defeat the OS keychain's own access control without a prompt. |
|
||
|
||
ADV2 is the adversary this architecture most under-models. Every other adversary must first
|
||
obtain something. Prompt injection obtains nothing: it borrows authority the client already
|
||
holds and is indistinguishable at the tool boundary from legitimate work. Each boundary
|
||
below therefore states whether it constrains ADV2 at all — and most do not, because they
|
||
authenticate the *caller*, not the *intent*.
|
||
|
||
## 3. Trust boundaries
|
||
|
||
"Crossing requires today" is what the code actually enforces at
|
||
`c1626081757dff76ea49fcb1869fc0362dea45b1`, not what the design intends.
|
||
|
||
| ID | Boundary | Protects | Crossing requires today | Crossing must require remotely |
|
||
| -- | -------- | -------- | ----------------------- | ------------------------------ |
|
||
| B1 | LLM client ↔ MCP server session | A1, A3, A10 — that a mutating session was established through the sanctioned client path | A single configured bind (`gitea_mcp_server.py:24728`) validated against one closed allowlist (`mcp_daemon_guard.py:49`, `mcp_daemon_guard.py:195`) — since #931 the identifier comes from deployment configuration and defaults to the local transport, so the boundary no longer rests on a literal, but it still rests on the *bind* rather than on an authenticated caller; client-managed provenance (`gitea_mcp_server.py:15415`) or a refusal (`gitea_mcp_server.py:15453`); production transport before recovery-authorization mint (`irrecoverable_provenance.py:497`, consumed at `gitea_mcp_server.py:9132` and `gitea_mcp_server.py:9381`) | An authenticated handshake issuing a server-side session identity bound to a principal, with the transport recorded in provenance. The physical proof (a pipe) must become a cryptographic one. |
|
||
| B2 | Role ↔ role | A9 — that author, reviewer, merger, and reconciler are distinct authorities | **The process boundary only.** The role is a property of the process, read once from `GITEA_MCP_PROFILE` (`gitea_config.py:54`). A caller gets author permissions by connecting to the author process. Review and merge are the operations singled out for extra care (`gitea_config.py:97`) | A per-request principal, so the role follows from the credential presented and cannot be selected by reaching a different endpoint. |
|
||
| B3 | MCP server ↔ credential store | A3, A8 — that only sanctioned code turns a profile into a token | `_keychain_token` shelling out to the login keychain (`gitea_config.py:956`), dispatched by `resolve_token` (`gitea_config.py:974`) with the reference type built at `gitea_config.py:1015`, gated by `assert_keychain_access_allowed` (`mcp_daemon_guard.py:583`). Inline secrets are rejected at config load (`gitea_config.py:294`) | A credential provider keyed by the *request* principal, returning only that principal's credential, with the source recorded and the value never returned. |
|
||
| B4 | MCP server ↔ Gitea | A1, A2 — that only authorized calls reach the forge | A bearer token over TLS. Server-side, nothing distinguishes one role's token from another beyond the account it belongs to | Unchanged at the forge; the endpoint in front of it must refuse unauthenticated and plaintext connections before tool dispatch. |
|
||
| B5 | MCP server ↔ caller's filesystem | A7 — that a tool acts on the *caller's* disk or refuses | Nothing. The server's disk *is* the caller's disk. Worktree bootstrap writes directly (`gitea_mcp_server.py:10897`); the active workspace is process-global (`gitea_mcp_server.py:193`, `gitea_mcp_server.py:194`) | An explicit per-tool classification, enforced at dispatch, refusing filesystem tools over a transport that cannot reach the caller's disk. A green verdict about the wrong disk is the failure to prevent. |
|
||
| B6 | MCP server ↔ coordination state | A6, A9 — mutual exclusion | Local files and a local SQLite database, with liveness judged from the local process table (`issue_lock_store.py:98`), keyed on paths under one user's home (`issue_lock_store.py:26`, `mcp_session_state.py:27`, `control_plane_db.py:47`) and on `os.getpid()` (`control_plane_db.py:1145`, `gitea_mcp_server.py:12804`). A legacy global slot still exists at `gitea_mcp_server.py:2351`, and the session-pointer file is named per PID (`issue_lock_store.py:83`) | One authority per ownership question, with liveness from session identity and expiry, and atomic acquire, renew, and release across hosts. |
|
||
| B7 | Gitea integration ↔ unrelated integrations | A4, A5 — that a Gitea compromise is not a CI and observability compromise | **Nothing.** See §5. The Gitea server reads Jenkins and GlitchTip secrets (`gitea_config.py:851`, reached from `gitea_config.py:837`) and holds the Sentry token (`sentry_incident_bridge.py:190`) | A hard process boundary. This is the boundary #956 exists to create. |
|
||
| B8 | Tenant ↔ tenant (`prgs` / `mdcps` / `local-lab`) | A2 — that one organization's compromise is not another's | Convention. One configuration declares all three contexts; `resolve_service` fails closed on a *disabled* context (`gitea_config.py:704`) but the credentials of enabled ones remain reachable in-process. A per-profile repository scope exists (`gitea_config.py:499`) | Separate deployments, or at minimum per-tenant credential scopes with no process able to resolve both. |
|
||
| B9 | Deployed code ↔ merged policy | A1, A10 — that the running server enforces the rules that were actually merged | Comparing this process's startup commit against this disk (`master_parity_gate.py:168`), conjoined into a single verdict (`master_parity_gate.py:255`) published by `gitea_mcp_server.py:19105` | Freshness defined against the deployed build identity, with an explicit fail-closed verdict when undeterminable. |
|
||
|
||
### What no boundary constrains
|
||
|
||
None of B1–B9 constrains **ADV2**. Every one authenticates a caller or a process; prompt
|
||
injection supplies neither. An injected instruction that reaches an authorized author
|
||
session crosses B1, B2, B3, and B5 legitimately, because at each of those boundaries it *is*
|
||
the author. The only controls that bite ADV2 are those constraining what an authenticated
|
||
principal may do regardless of what it asks for — the per-role permission split (B2), the
|
||
repository scope at `gitea_config.py:499`, and separation of duty (A9). Sizing those
|
||
controls correctly matters more after the migration, not less, because a remote endpoint
|
||
raises the number of clients that can be injected into.
|
||
|
||
## 4. Data flows
|
||
|
||
Flows that cross a boundary. `==>` carries a credential; `-->` does not.
|
||
|
||
```
|
||
B1 B4
|
||
[LLM client] ====================> [MCP server] ========> [Gitea]
|
||
^ stdio pipe today | ^ (A1,A2)
|
||
| session identity | |
|
||
| after migration | |
|
||
| | | B3
|
||
untrusted repository content | +======> [macOS login keychain] (A8)
|
||
read back into the model (ADV2) | resolves A3, A4, A5
|
||
^ |
|
||
+----------------------------------+
|
||
|
|
||
B5 | B6
|
||
[operator checkout / worktrees] <--------+-------> [locks · leases · sqlite]
|
||
(A7) | (A6)
|
||
|
|
||
B7 <-- boundary does not exist today
|
||
|
|
||
+========================+========================+
|
||
| | |
|
||
[Jenkins] (A4) [GlitchTip] (A5) [Sentry] (A5)
|
||
external MCP server external MCP server in-process bridge
|
||
```
|
||
|
||
Two flows deserve attention because neither is obvious from the code:
|
||
|
||
1. **The keychain flow fans out.** B3 is drawn once but resolves credentials for *every*
|
||
configured profile and service, not only the active one. `gitea_list_profiles`
|
||
(`gitea_mcp_server.py:19261`) reports each profile's credential status by calling
|
||
`resolve_token` on it (`gitea_mcp_server.py:19312`), and `gitea_audit_config`
|
||
(`gitea_mcp_server.py:19555`) reports service credential status through
|
||
`service_summaries` (`gitea_mcp_server.py:19577`).
|
||
2. **The return path is a flow too.** Content read from Gitea travels back into the model
|
||
and is treated as instruction. This is the ADV2 edge, and it is the only edge in the
|
||
diagram with no authentication on it, because it is not a request.
|
||
|
||
## 5. Per-boundary credential inventory
|
||
|
||
**14 credentials in total.** Blast radius is stated as what the credential yields *on its
|
||
own*, assuming every gate not backed by the credential itself has been bypassed — because
|
||
an attacker holding a token calls the API, not our tools.
|
||
|
||
| ID | Credential | Holder | Boundary | Blast radius |
|
||
| -- | ---------- | ------ | -------- | ------------ |
|
||
| CR1 | `prgs-author` Gitea token — account `jcwalker3` | macOS keychain; resolved in-process (`gitea_config.py:974`) | B3 → B4 | Create branches, push, commit, open PRs, create/close/comment issues on the control-plane repo. Cannot approve or merge. The one credential whose identity is genuinely distinct. |
|
||
| CR2 | `prgs-reviewer` Gitea token — account `sysadmin` | macOS keychain | B3 → B4 | Approve and request changes. **Shares one Gitea account with CR3, CR4, CR5.** |
|
||
| CR3 | `prgs-merger` Gitea token — account `sysadmin` | macOS keychain | B3 → B4 | Merge to `master` — A1 in full. Same account as CR2. |
|
||
| CR4 | `prgs-reconciler` Gitea token — account `sysadmin` | macOS keychain | B3 → B4 | Close PRs, delete branches, irrecoverable decision-lock recovery. Same account as CR2. |
|
||
| CR5 | `prgs-controller` Gitea token — account `sysadmin` | macOS keychain | B3 → B4 | Same operation set as CR4. Same account as CR2. |
|
||
| CR6 | `mdcps-author` Gitea token — account `913443` | macOS keychain | B3 → B4, B8 | Author operations on a second organization. **Shares one account with CR7 and CR8.** |
|
||
| CR7 | `mdcps-reviewer` Gitea token — account `913443` | macOS keychain | B3 → B4, B8 | Approve and request changes on `mdcps`. Same account as CR6. |
|
||
| CR8 | `mdcps-merger` Gitea token — account `913443` | macOS keychain | B3 → B4, B8 | Merge on `mdcps` — A2 in full. Same account as CR6. |
|
||
| CR9 | MDCPS Jenkins read credential | macOS keychain, read from the Gitea server process (`gitea_config.py:851`) | B7 | Read CI jobs, builds, and logs (A4). Enabled today. |
|
||
| CR10 | MDCPS GlitchTip read credential | macOS keychain, read from the Gitea server process (`gitea_config.py:851`) | B7 | Read error events and their payloads (A5). Enabled today. |
|
||
| CR11 | `SENTRY_AUTH_TOKEN` | Process environment, read in-process (`sentry_incident_bridge.py:36`, `sentry_incident_bridge.py:190`), sent as a bearer header (`sentry_incident_bridge.py:289`) | B7 | Read and reconcile Sentry issues (A5). Not a keychain credential — an env var, so it is inherited by anything the process spawns. |
|
||
| CR12 | `SENTRY_DSN` | Process environment (`sentry_observability.py:55`) | B7 | Write events into the observability project. Low read value, real forgery value: an attacker can inject fabricated events into the record (A10). |
|
||
| CR13 | macOS login keychain access | The operator's login session; gated by `assert_keychain_access_allowed` (`mcp_daemon_guard.py:583`) | B3, ADV5 | **Every other credential in this table except CR11 and CR12.** This is the aggregation point. |
|
||
| CR14 | Coordination-store access (no secret) | Filesystem permissions — `control_plane_db.py:47`, created `0o700` (`control_plane_db.py:380`), opened with a local file lock (`control_plane_db.py:386`) | B6, ADV5 | Full read/write of locks, leases, and decision records (A6). **There is no credential here at all** — anything running as the operator can rewrite ownership. |
|
||
|
||
### Findings
|
||
|
||
**Finding 1 — Role separation is not credential separation.** Four `prgs` roles resolve to
|
||
one Gitea account (`sysadmin`): reviewer, merger, reconciler, and controller. A stolen
|
||
reviewer credential *is* a merger credential. A9 — separation of duty between approving and
|
||
landing — is therefore enforced entirely by which local process a call reaches (B2), and not
|
||
at all by the forge. It survives exactly as long as B2 does, and B2 is the boundary the
|
||
migration dissolves.
|
||
|
||
**Finding 2 — The `mdcps` tenant has no role separation at all.** Author, reviewer, and
|
||
merger all resolve to account `913443`. One credential can open a PR, approve it, and merge
|
||
it. The in-process self-review check compares the authenticated username against the PR
|
||
author and would refuse — but that check runs on our side of B4. It is not a property of
|
||
the credential, and an attacker holding the token does not call our tools.
|
||
|
||
**Finding 3 — Any one role process can resolve every other role's credential.** This is not
|
||
inferred; it is demonstrated by tool output. `gitea_list_profiles`
|
||
(`gitea_mcp_server.py:19261`) called from the **author** session reports
|
||
`identity_status: "credentials present"` for `prgs-merger`, `prgs-reviewer`,
|
||
`prgs-reconciler`, and every `mdcps` profile, because it calls `resolve_token` on each one
|
||
(`gitea_mcp_server.py:19312`). The author process does not merely *have access to* the
|
||
merger's credential — it reads it to answer a status query. B2 is not a credential boundary
|
||
in either direction.
|
||
|
||
**Finding 4 — The Gitea server reads CI and observability secrets.** `gitea_audit_config`
|
||
(`gitea_mcp_server.py:19555`) reports `MDCPS Jenkins: enabled, read-only, authenticated`.
|
||
That word `authenticated` is produced by `service_summaries` (`gitea_mcp_server.py:19577`,
|
||
defined at `gitea_config.py:837`), whose default check calls `_keychain_token` on the
|
||
service's own keychain reference (`gitea_config.py:851`). Producing that one line requires
|
||
the Gitea MCP server to read the Jenkins secret and the GlitchTip secret out of the
|
||
keychain. B7 does not exist.
|
||
|
||
**Finding 5 — Jenkins and GlitchTip are already decomposed; the reach is residual.** Their
|
||
tools live in separately registered servers, marked `external-mcp`
|
||
(`gitea_mcp_server.py:17710`, `gitea_mcp_server.py:17716`, `gitea_mcp_server.py:17737`,
|
||
`gitea_mcp_server.py:17742`) with their own expected tool sets (`mcp_discoverability.py:9`,
|
||
`mcp_discoverability.py:17`). The correct decomposition was already chosen. What remains is
|
||
a leak across it: the credential *references* still live in the Gitea configuration and are
|
||
still resolved by the Gitea process. #75 bundled these services into one control-plane
|
||
umbrella; the tools were separated afterwards, the credentials were not.
|
||
|
||
**Finding 6 — Sentry is the exception that is not decomposed.** Unlike Jenkins and
|
||
GlitchTip, the Sentry bridge runs *inside* the Gitea server, resolving its token from the
|
||
process environment (`sentry_incident_bridge.py:190`) and sending it as a bearer header
|
||
(`sentry_incident_bridge.py:289`). Being an environment variable rather than a keychain item
|
||
makes it strictly worse: it needs no keychain prompt and is inherited by every subprocess the
|
||
server spawns — including the `ps` invocations at `gitea_mcp_server.py:21465` and
|
||
`gitea_mcp_server.py:21509`, reached from `gitea_mcp_server.py:21445`.
|
||
|
||
**Finding 7 — The highest-value coordination asset has the weakest gate.** A6 is protected
|
||
by filesystem permissions alone (CR14). Corrupting a lease requires no Gitea credential,
|
||
produces no forge-side audit record, and breaks the mutual exclusion the entire workflow
|
||
assumes. Every other asset costs an attacker a credential; this one costs nothing beyond
|
||
local access, which is exactly ADV5's position.
|
||
|
||
**Finding 8 — Provenance authenticates the launch, not the caller.** `server_provenance` is
|
||
reported as exactly `client_managed` or `manual_launch` (`gitea_mcp_server.py:19004`),
|
||
derived from environment inspection (`gitea_mcp_server.py:15415`) with the recognized-key
|
||
allowlist at `gitea_config.py:1172` and the generator that emits the marker at
|
||
`gitea_config.py:1233`. Every one of those facts is fixed at process start. A client that is
|
||
trustworthy at launch and compromised a minute later remains `client_managed` for the life
|
||
of the process, and the transport contract that underwrites it is stated as a property of
|
||
the server itself (`mcp_server.py:4`). Since #931 that contract names the configured
|
||
transport rather than asserting stdio, but it is still fixed once, at bind, for the life of
|
||
the process.
|
||
|
||
## 6. Decomposition ruling
|
||
|
||
This section is the ruling #956 requires. It is a decision, not a recommendation.
|
||
|
||
**D1 — No unrelated co-residency.** A single integration process **must not** hold, resolve,
|
||
or be able to resolve credentials for services it does not itself integrate with.
|
||
Concretely: the Gitea MCP service may hold Gitea credentials and nothing else. Jenkins,
|
||
GlitchTip, Sentry, and any database credential are **not permitted** to co-reside with Gitea
|
||
credentials in one process.
|
||
|
||
*Rationale.* A process is the smallest unit an attacker takes whole. Once ADV1 or ADV2
|
||
controls execution in a process, every credential that process can resolve is theirs, and no
|
||
in-process check helps, because the checks are in the process too. Blast radius is therefore
|
||
a property of the process boundary and nothing finer. Findings 4 and 6 show that today one
|
||
compromise of the Gitea server yields CI read access, error-tracking read access, and — via
|
||
CR13 — every role credential on both tenants. That is the single largest reduction in blast
|
||
radius available anywhere in epic #929, and it costs no new mechanism: the decomposition
|
||
already exists (Finding 5) and is merely leaked across.
|
||
|
||
**D2 — Separation of duty must be backed by credentials.** Two roles whose separation is a
|
||
security property must not resolve to the same forge account. Specifically, reviewer and
|
||
merger must be distinct accounts. Today they are not, on either tenant (Findings 1 and 2).
|
||
|
||
*Rationale.* B2 is a process boundary, and the migration's entire purpose is to replace
|
||
process boundaries with request-level ones. A separation enforced only by which process a
|
||
call reaches does not survive that replacement — and it is already bypassable by anyone who
|
||
holds the token and calls the API instead of the tool.
|
||
|
||
**D3 — Credential resolution is scoped to the request principal.** A session must resolve its
|
||
own credential and must have no path to any other principal's. The resolve-every-profile
|
||
behavior behind `gitea_mcp_server.py:19312` and `gitea_mcp_server.py:19577` must report
|
||
configured-or-not from configuration alone, without resolving the secret.
|
||
|
||
*Rationale.* Finding 3. An audit surface that proves a credential exists by fetching it is a
|
||
credential-aggregation primitive wearing a diagnostic's clothes.
|
||
|
||
**D4 — Coordination state is a protected asset with its own authority.** Access to locks,
|
||
leases, and decision records must require an authenticated session, not merely local
|
||
filesystem access.
|
||
|
||
*Rationale.* Finding 7. #937 already moves this store for concurrency reasons; the
|
||
authorization requirement must land with it, or the store becomes remotely reachable while
|
||
still being authorized by nothing.
|
||
|
||
### Exceptions
|
||
|
||
**One, time-boxed.** During the dual-run window defined by #939, the **local** stdio fleet
|
||
may continue to resolve Jenkins and GlitchTip credential *references* from the shared
|
||
configuration, because removing them from the local configuration is not a prerequisite for
|
||
standing up the remote endpoint and would strand the operator's existing local workflow.
|
||
|
||
This exception is bounded by all of:
|
||
|
||
- It applies to the local stdio deployment only. The remote endpoint (#938) must be
|
||
configured with Gitea credentials and no others from its first day.
|
||
- It expires when #939 completes. It does not survive cutover.
|
||
- It does not extend to Sentry: CR11 and CR12 are process-environment credentials in the
|
||
Gitea server (Finding 6) and must be absent from the remote deployment's environment
|
||
regardless of dual-run state.
|
||
|
||
No exception is granted to D2, D3, or D4.
|
||
|
||
### Consequences for the target architecture
|
||
|
||
- The remote endpoint serves **Gitea only**. It is not a general control-plane endpoint.
|
||
- Jenkins and GlitchTip keep their existing separate servers, and their credential
|
||
references move out of the Gitea configuration.
|
||
- The Sentry bridge either moves behind its own service boundary or is absent from the
|
||
remote deployment. It does not travel with the Gitea server.
|
||
- Reviewer and merger accounts diverge before the endpoint is trusted for merges, or A9 is
|
||
recorded as unenforced.
|
||
|
||
## 7. Child-to-boundary mapping
|
||
|
||
Every #929 child from 2 through 10, mapped to the boundary it implements. A child
|
||
implementing more than one boundary names its primary first.
|
||
|
||
| Child | Issue | Boundaries | What it must establish | Rulings it must honor |
|
||
| ----: | ----- | ---------- | ---------------------- | --------------------- |
|
||
| 2 | #931 | B1, B9 | The bound transport becomes a validated value that provenance and freshness can both key on. Without it neither B1 nor B9 has an input. | — |
|
||
| 3 | #932 | B2 | The role becomes a property of the request, not the process — the boundary the migration otherwise deletes. | D2, D3 |
|
||
| 4 | #933 | B3, B7 | Credentials come from a provider keyed by principal. This is where D1 and D3 are either enforced or permanently lost. | D1, D3 |
|
||
| 5 | #934 | B1 | Session provenance replaces pipe-and-process-table proof with an authenticated session identity. | — |
|
||
| 6 | #935 | B9 | Freshness redefined against deployed build identity, with an explicit undeterminable verdict. | — |
|
||
| 7 | #936 | B5 | Every tool classified and the filesystem boundary enforced at dispatch, so a tool cannot return green about the wrong disk. | — |
|
||
| 8 | #937 | B6 | One authority per ownership question, with session-identity liveness and atomic transitions. | D4 |
|
||
| 9 | #938 | B4, B1, B8 | The endpoint: authentication, principal binding, transport security, and — critically — the deployed credential set. | D1, D2, D3 |
|
||
| 10 | #939 | B6 | Dual-run with exactly one coordination authority at every instant, and the rollback that proves the way back. | D1 exception expiry |
|
||
|
||
Boundary coverage: B1 (#931, #934, #938), B2 (#932), B3 (#933), B4 (#938), B5 (#936),
|
||
B6 (#937, #939), B7 (#933), B8 (#938), B9 (#931, #935).
|
||
|
||
B7 has exactly one owner, #933, and that is deliberate. B7 is not created by standing up an
|
||
endpoint; it is created by deciding which credentials a process may resolve, which is
|
||
precisely what the credential-provider child does.
|
||
|
||
## 8. Adversarial walkthrough: a fully compromised LLM client
|
||
|
||
The required negative case. ADV1 holds one MCP client and issues arbitrary tool calls. What
|
||
does it reach at each boundary?
|
||
|
||
### Before the migration
|
||
|
||
The client is attached to the local fleet over stdio.
|
||
|
||
| Boundary | What ADV1 reaches | Stopped by |
|
||
| -------- | ----------------- | ---------- |
|
||
| B1 | Everything the fleet serves. The client *is* the sanctioned launcher: it satisfies the client-managed check (`gitea_mcp_server.py:15415`) by construction, and provenance is never re-verified after launch (Finding 8). | Nothing. The guard authenticates the launch, not the caller. |
|
||
| B2 | All five roles — it is attached to all five namespaces. It can author a PR, approve it from the reviewer namespace, and merge it from the merger namespace. | Only the in-process self-review check, which compares `jcwalker3` (author) against `sysadmin` (reviewer) and **passes**, because Finding 1 made them different accounts while leaving reviewer and merger identical. A9 falls in one sequence of legitimate calls. |
|
||
| B3 | Every credential in CR1–CR10 via CR13, with no additional prompt — the daemon is already sanctioned, so `assert_keychain_access_allowed` (`mcp_daemon_guard.py:583`) returns immediately. | Nothing. |
|
||
| B4 | A1 and A2 in full. | Branch protection at the forge, to the extent configured. |
|
||
| B5 | The operator's checkout and every worktree, through the author tools (`gitea_mcp_server.py:10897`), plus the shared stderr path at `mcp_server.py:13`. | Nothing; the server's disk is the target disk. |
|
||
| B6 | All coordination state — no credential required (CR14). It can forge lease ownership and clear decision locks. | Filesystem permissions, which it already satisfies. |
|
||
| B7 | Jenkins (A4) and GlitchTip (A5) secrets via Finding 4, and CR11/CR12 from its own environment. | Nothing. |
|
||
| B8 | Both tenants. | Nothing in-process; only the disabled-context check (`gitea_config.py:704`), which does not apply to enabled contexts. |
|
||
| B9 | Can it run stale code? Only by advancing the checkout, which parity detects (`master_parity_gate.py:255`). | This gate holds — the one boundary that behaves as designed against ADV1. |
|
||
|
||
**Result: total.** One compromised client yields A1 through A8 and A10. The only asset with
|
||
real resistance is A1 via branch protection, and the client holds the merger credential
|
||
anyway. Nine boundaries, one meaningful stop.
|
||
|
||
### After the migration
|
||
|
||
The same client authenticates to the remote endpoint with one role's credential, assuming
|
||
#931–#939 land **and honor D1–D4**.
|
||
|
||
| Boundary | What ADV1 reaches | Stopped by |
|
||
| -------- | ----------------- | ---------- |
|
||
| B1 | One authenticated session, bound to one principal. | #934: a forged or expired session identity is refused; the client cannot mint one. |
|
||
| B2 | **One role.** Presenting the author credential yields author permissions only. | #932: the principal comes from the credential, not from which endpoint was reached. |
|
||
| B3 | **One credential — its own.** | #933 with D3: the provider resolves by principal, and no diagnostic resolves the others. |
|
||
| B4 | That role's authority on the forge. | Endpoint authentication (#938); plaintext and unauthenticated attempts refused before dispatch. |
|
||
| B5 | **Nothing.** Filesystem tools are refused over the remote transport with a named blocker. | #936. |
|
||
| B6 | Its own leases; contention resolves to exactly one winner. | #937 with D4: authenticated session required, not filesystem access. |
|
||
| B7 | **Nothing.** No CI or observability credential exists in the process. | D1 — the single largest reduction on this table. |
|
||
| B8 | One tenant. | D1 and #938: the deployment carries one tenant's credentials. |
|
||
| B9 | Cannot induce stale enforcement. | #935: explicit fail-closed verdict, including undeterminable. |
|
||
|
||
**Result: bounded.** The compromise is contained to one role on one tenant, with no
|
||
filesystem reach and no lateral credential access. A9 survives *only if D2 lands* — if
|
||
reviewer and merger still share `sysadmin`, a compromised reviewer session still merges, and
|
||
this row reads the same after the migration as before it.
|
||
|
||
### What the migration does not fix
|
||
|
||
Against **ADV2**, both tables are identical. Prompt injection does not need to cross a
|
||
boundary: it arrives inside an authorized session and asks that session to do what it is
|
||
already permitted to do. Every "stopped by" above authenticates a principal, and the
|
||
injected instruction has the correct principal. The migration reduces ADV1's blast radius by
|
||
roughly an order of magnitude and reduces ADV2's by nothing.
|
||
|
||
The controls that do constrain ADV2 are per-principal permission scope (#932), repository
|
||
scope (`gitea_config.py:499`), and credential-backed separation of duty (D2) — each limiting
|
||
what an authenticated session may do *regardless of what it is asked for*. #955's
|
||
secure-isolation end state should be read with that distinction in mind: removing credentials
|
||
from clients defeats ADV1 and ADV5, and does not by itself defeat ADV2.
|
||
|
||
Two further items are explicitly out of scope here and unowned by #929:
|
||
|
||
- **Session-credential rotation and revocation.** #938 names rotation as documentation, but
|
||
no child owns proving that a revoked credential stops an in-flight session.
|
||
- **ADV3** (malicious tool arguments) is diffused across every child rather than owned. The
|
||
per-request principal work in #932 is the natural place to assert that identifiers taken
|
||
from the request never authorize anything on their own.
|
||
|
||
## 9. How to verify this document
|
||
|
||
1. `PYTHONPATH=. pytest tests/test_issue_956_threat_model.py` — resolves every anchor
|
||
against the working tree and checks the document's structural obligations.
|
||
2. Pick any five anchors at random and read them; the fixture states what each line must
|
||
contain.
|
||
3. Reproduce Findings 3 and 4 live: call `gitea_list_profiles` and `gitea_audit_config`
|
||
from the **author** namespace. Credential presence reported for roles other than the
|
||
active one is Finding 3; `MDCPS Jenkins: enabled, read-only, authenticated` is Finding 4.
|
||
|
||
If the anchor test fails after an unrelated refactor, the anchors moved and the fixture
|
||
needs regenerating — the claims are still true, but they are no longer traceable, which
|
||
#956 treats as the same defect.
|