Closes #931.
The transport was a literal passed once at the bottom of the module,
bind_native_mcp_transport(transport="stdio"), and every guard that asks
"is this a trusted native session" resolves that question through the
value bound there. The string was a constant in the authorization chain
rather than configuration, so a remote transport had no way to be
expressed and no way to be told apart from an untrusted offline import.
mcp_transport_config becomes the single source of truth: it owns the
permitted set, the default, and the resolution of deployment
configuration (GITEA_MCP_TRANSPORT) into a validated identifier. Unset
configuration still yields stdio, so existing deployments are unchanged.
streamable-http is accepted so the bind is pluggable; standing up its
listener remains #938. sse is deliberately not registered.
bind_native_mcp_transport now resolves from configuration when no
transport argument is given, validates against that one permitted set
before writing the runtime record, and pins the result. bound_transport
is the shared accessor every transport-aware guard reads; it reports the
pinned value and never the environment, so a post-bind GITEA_MCP_TRANSPORT
change cannot move what a guard observes -- the rule already applied to
the session-state root under #695 AC2. Rebinding the same identifier is
idempotent; rebinding a different one is refused, so two guards can never
disagree within one process. assert_transport_bound fails closed before
mcp.run, so an absent or invalid bind stops the server instead of serving
tools over a transport no guard can name.
The bound identifier is now recorded in durable provenance:
mutation_provenance_fields gains bound_transport, which reaches the
decision lock through mcp_session_state.save_state and the audit records
that already spread those fields. The pre-existing transport field keeps
its trust-class values, so no durable record changes shape.
assess_transport_for_auth_mint reports the bound transport through the
same accessor; its verdict is unchanged.
#956's anchor fixture and threat model are re-anchored for the lines this
change shifts, and the two boundary claims that #931 makes false -- the
"literal stdio bind" in B1 and the stdio contract at mcp_server.py:4 --
are restated. Without this the #956 guard fails, which is what it is for.
Non-goals, untouched: the remote listener (#938), per-request principal
resolution (#932), client-managed provenance policy (#934), TLS and
remote client authentication, role capability sets, repository-binding
semantics.
Tests: tests/test_issue_931_transport_bind_seam.py, 42 tests, covering
default bind, explicit stdio, the accepted non-stdio identifier, an
unregistered identifier, no bind at all, one-value agreement across
guards, post-bind environment tampering, the durable decision-lock
record, the rebind contract, and the #695 protections that must not
regress.
Full suite from the branches/ worktree: 28 failed, 5843 passed, 6
skipped, 1047 subtests. The pinned base 9b80e75c reports 28 failed, 5801
passed, 6 skipped, 1047 subtests. The failing test IDs are identical sets
-- no failure added, none fixed; the +42 passed are this PR's new module.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
406 lines
34 KiB
Markdown
406 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:** `aad5c8b42361d380a8eeb07b94b90815e594c2c5` (`master`).
|
||
- **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
|
||
`aad5c8b42361d380a8eeb07b94b90815e594c2c5`, 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:178`) — 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:519`). 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:519`) | 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:519`) 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.
|