diff --git a/docs/remote-mcp/threat-model-anchors.json b/docs/remote-mcp/threat-model-anchors.json index 24e9dd2..33e81ef 100644 --- a/docs/remote-mcp/threat-model-anchors.json +++ b/docs/remote-mcp/threat-model-anchors.json @@ -8,10 +8,10 @@ "document. #930's inventory had no such guard and its gitea_mcp_server.py", "anchors drifted between 7bf4f125 and aad5c8b4." ], - "generated_against_commit": "e3fa3b263d4b8a04b189e111a345ac5c3a3fe1b6", + "generated_against_commit": "ca5f078d8a575ea3e2991771f8b4ea85e3dcaaa0", "anchors": [ { - "anchor": "gitea_mcp_server.py:24848", + "anchor": "gitea_mcp_server.py:24864", "expect": "mcp_daemon_guard.bind_native_mcp_transport()" }, { @@ -27,11 +27,11 @@ "expect": "def assess_transport_for_auth_mint" }, { - "anchor": "gitea_mcp_server.py:9175", + "anchor": "gitea_mcp_server.py:9191", "expect": "assess_transport_for_auth_mint()" }, { - "anchor": "gitea_mcp_server.py:9424", + "anchor": "gitea_mcp_server.py:9440", "expect": "assess_transport_for_auth_mint()" }, { @@ -39,31 +39,31 @@ "expect": "The transport is selected by deployment configuration" }, { - "anchor": "gitea_mcp_server.py:15465", + "anchor": "gitea_mcp_server.py:15481", "expect": "def _is_client_managed_process" }, { - "anchor": "gitea_mcp_server.py:15495", + "anchor": "gitea_mcp_server.py:15511", "expect": "def _provenance_mutation_block" }, { - "anchor": "gitea_mcp_server.py:15503", + "anchor": "gitea_mcp_server.py:15519", "expect": "unsupported_manual_launch" }, { - "anchor": "gitea_mcp_server.py:19054", + "anchor": "gitea_mcp_server.py:19070", "expect": "server_provenance" }, { - "anchor": "gitea_mcp_server.py:21565", + "anchor": "gitea_mcp_server.py:21581", "expect": "def _check_mcp_runtimes_diagnostics" }, { - "anchor": "gitea_mcp_server.py:21585", + "anchor": "gitea_mcp_server.py:21601", "expect": "\"ps\", \"-o\", \"pid,lstart,command\"" }, { - "anchor": "gitea_mcp_server.py:21629", + "anchor": "gitea_mcp_server.py:21645", "expect": "\"ps\", \"eww\"" }, { @@ -107,19 +107,19 @@ "expect": "def assert_keychain_access_allowed" }, { - "anchor": "gitea_mcp_server.py:19311", + "anchor": "gitea_mcp_server.py:19327", "expect": "def gitea_list_profiles" }, { - "anchor": "gitea_mcp_server.py:19362", + "anchor": "gitea_mcp_server.py:19378", "expect": "gitea_config.resolve_token(p)" }, { - "anchor": "gitea_mcp_server.py:19675", + "anchor": "gitea_mcp_server.py:19691", "expect": "def gitea_audit_config" }, { - "anchor": "gitea_mcp_server.py:19697", + "anchor": "gitea_mcp_server.py:19713", "expect": "service_summaries(config)" }, { @@ -135,19 +135,19 @@ "expect": "_keychain_token(auth.get(\"id\"))" }, { - "anchor": "gitea_mcp_server.py:17760", + "anchor": "gitea_mcp_server.py:17776", "expect": "\"jenkins-mcp\"" }, { - "anchor": "gitea_mcp_server.py:17766", + "anchor": "gitea_mcp_server.py:17782", "expect": "external-mcp" }, { - "anchor": "gitea_mcp_server.py:17787", + "anchor": "gitea_mcp_server.py:17803", "expect": "\"glitchtip-mcp\"" }, { - "anchor": "gitea_mcp_server.py:17792", + "anchor": "gitea_mcp_server.py:17808", "expect": "external-mcp" }, { @@ -183,7 +183,7 @@ "expect": "mutation_safe" }, { - "anchor": "gitea_mcp_server.py:19155", + "anchor": "gitea_mcp_server.py:19171", "expect": "def gitea_assess_master_parity" }, { @@ -199,7 +199,7 @@ "expect": "/tmp/gitea_issue_lock.json" }, { - "anchor": "gitea_mcp_server.py:10940", + "anchor": "gitea_mcp_server.py:10956", "expect": "def gitea_bootstrap_author_issue_worktree" }, { @@ -239,7 +239,7 @@ "expect": "os.getpid()" }, { - "anchor": "gitea_mcp_server.py:12854", + "anchor": "gitea_mcp_server.py:12870", "expect": "owner_pid_alive" } ] diff --git a/docs/remote-mcp/threat-model.md b/docs/remote-mcp/threat-model.md index 9518185..ddbb7a6 100644 --- a/docs/remote-mcp/threat-model.md +++ b/docs/remote-mcp/threat-model.md @@ -5,9 +5,11 @@ What the adversary is, what each boundary protects, and which services may share - **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:** `a143cd065ba06e1a2bdc5143a19ec156e53650ef` (#931's transport - bind seam). Originally generated against `aad5c8b42361d380a8eeb07b94b90815e594c2c5` - (`master`) and re-anchored when #931 shifted the cited lines. +- **Generated against commit:** `ca5f078d8a575ea3e2991771f8b4ea85e3dcaaa0` (#708's + namespace-attachment gate). Originally generated against + `aad5c8b42361d380a8eeb07b94b90815e594c2c5` (`master`), re-anchored at + `a143cd065ba06e1a2bdc5143a19ec156e53650ef` when #931's transport bind seam shifted the + cited lines, and re-anchored again when #708 shifted them further. - **Scope:** documentation only. This child changes no server behavior. It adds one document, one anchor fixture, and the test that enforces them. @@ -29,7 +31,7 @@ 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:24848`, and its +bind it cited at line 23750 now lives at `gitea_mcp_server.py:24864`, 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 @@ -73,19 +75,19 @@ authenticate the *caller*, not the *intent*. ## 3. Trust boundaries "Crossing requires today" is what the code actually enforces at -`a143cd065ba06e1a2bdc5143a19ec156e53650ef`, not what the design intends. +`ca5f078d8a575ea3e2991771f8b4ea85e3dcaaa0`, 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:24848`) 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:15465`) or a refusal (`gitea_mcp_server.py:15503`); production transport before recovery-authorization mint (`irrecoverable_provenance.py:497`, consumed at `gitea_mcp_server.py:9175` and `gitea_mcp_server.py:9424`) | 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. | +| 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:24864`) 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:15481`) or a refusal (`gitea_mcp_server.py:15519`); production transport before recovery-authorization mint (`irrecoverable_provenance.py:497`, consumed at `gitea_mcp_server.py:9191` and `gitea_mcp_server.py:9440`) | 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:10940`); 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:12854`). 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. | +| 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:10956`); 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:12870`). 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:19155` | Freshness defined against the deployed build identity, with an explicit fail-closed verdict when undeterminable. | +| 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:19171` | Freshness defined against the deployed build identity, with an explicit fail-closed verdict when undeterminable. | ### What no boundary constrains @@ -130,10 +132,10 @@ 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:19311`) reports each profile's credential status by calling - `resolve_token` on it (`gitea_mcp_server.py:19362`), and `gitea_audit_config` - (`gitea_mcp_server.py:19675`) reports service credential status through - `service_summaries` (`gitea_mcp_server.py:19697`). + (`gitea_mcp_server.py:19327`) reports each profile's credential status by calling + `resolve_token` on it (`gitea_mcp_server.py:19378`), and `gitea_audit_config` + (`gitea_mcp_server.py:19691`) reports service credential status through + `service_summaries` (`gitea_mcp_server.py:19713`). 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. @@ -178,16 +180,16 @@ 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:19311`) called from the **author** session reports +(`gitea_mcp_server.py:19327`) 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:19362`). The author process does not merely *have access to* the +(`gitea_mcp_server.py:19378`). 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:19675`) reports `MDCPS Jenkins: enabled, read-only, authenticated`. -That word `authenticated` is produced by `service_summaries` (`gitea_mcp_server.py:19697`, +(`gitea_mcp_server.py:19691`) reports `MDCPS Jenkins: enabled, read-only, authenticated`. +That word `authenticated` is produced by `service_summaries` (`gitea_mcp_server.py:19713`, 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 @@ -195,8 +197,8 @@ 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:17760`, `gitea_mcp_server.py:17766`, `gitea_mcp_server.py:17787`, -`gitea_mcp_server.py:17792`) with their own expected tool sets (`mcp_discoverability.py:9`, +(`gitea_mcp_server.py:17776`, `gitea_mcp_server.py:17782`, `gitea_mcp_server.py:17803`, +`gitea_mcp_server.py:17808`) 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 @@ -207,8 +209,8 @@ GlitchTip, the Sentry bridge runs *inside* the Gitea server, resolving its token 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:21585` and -`gitea_mcp_server.py:21629`, reached from `gitea_mcp_server.py:21565`. +server spawns — including the `ps` invocations at `gitea_mcp_server.py:21601` and +`gitea_mcp_server.py:21645`, reached from `gitea_mcp_server.py:21581`. **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, @@ -217,8 +219,8 @@ assumes. Every other asset costs an attacker a credential; this one costs nothin 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:19054`), -derived from environment inspection (`gitea_mcp_server.py:15465`) with the recognized-key +reported as exactly `client_managed` or `manual_launch` (`gitea_mcp_server.py:19070`), +derived from environment inspection (`gitea_mcp_server.py:15481`) 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 @@ -257,7 +259,7 @@ 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:19362` and `gitea_mcp_server.py:19697` must report +behavior behind `gitea_mcp_server.py:19378` and `gitea_mcp_server.py:19713` 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 @@ -334,11 +336,11 @@ 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:15465`) by construction, and provenance is never re-verified after launch (Finding 8). | Nothing. The guard authenticates the launch, not the caller. | +| B1 | Everything the fleet serves. The client *is* the sanctioned launcher: it satisfies the client-managed check (`gitea_mcp_server.py:15481`) 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:10940`), plus the shared stderr path at `mcp_server.py:13`. | Nothing; the server's disk is the target disk. | +| B5 | The operator's checkout and every worktree, through the author tools (`gitea_mcp_server.py:10956`), 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. |