feat(transport): add transport-neutral MCP bind seam

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]>
This commit is contained in:
2026-07-28 09:59:01 -05:00
co-authored by Claude Opus 4.8
parent 9b80e75ca3
commit c162608175
9 changed files with 942 additions and 75 deletions
+29 -29
View File
@@ -10,21 +10,21 @@
],
"generated_against_commit": "aad5c8b42361d380a8eeb07b94b90815e594c2c5",
"anchors": [
{"anchor": "gitea_mcp_server.py:24721", "expect": "bind_native_mcp_transport(transport=\"stdio\")"},
{"anchor": "mcp_daemon_guard.py:45", "expect": "_PRODUCTION_TRANSPORTS = frozenset({\"stdio\"})"},
{"anchor": "mcp_daemon_guard.py:174", "expect": "def bind_native_mcp_transport"},
{"anchor": "gitea_mcp_server.py:24728", "expect": "mcp_daemon_guard.bind_native_mcp_transport()"},
{"anchor": "mcp_daemon_guard.py:49", "expect": "_PRODUCTION_TRANSPORTS = mcp_transport_config.SUPPORTED_TRANSPORTS"},
{"anchor": "mcp_daemon_guard.py:178", "expect": "def bind_native_mcp_transport"},
{"anchor": "irrecoverable_provenance.py:497", "expect": "def assess_transport_for_auth_mint"},
{"anchor": "gitea_mcp_server.py:9129", "expect": "assess_transport_for_auth_mint()"},
{"anchor": "gitea_mcp_server.py:9378", "expect": "assess_transport_for_auth_mint()"},
{"anchor": "mcp_server.py:4", "expect": "Runs over stdio."},
{"anchor": "gitea_mcp_server.py:9132", "expect": "assess_transport_for_auth_mint()"},
{"anchor": "gitea_mcp_server.py:9381", "expect": "assess_transport_for_auth_mint()"},
{"anchor": "mcp_server.py:4", "expect": "The transport is selected by deployment configuration"},
{"anchor": "gitea_mcp_server.py:15412", "expect": "def _is_client_managed_process"},
{"anchor": "gitea_mcp_server.py:15442", "expect": "def _provenance_mutation_block"},
{"anchor": "gitea_mcp_server.py:15450", "expect": "unsupported_manual_launch"},
{"anchor": "gitea_mcp_server.py:19001", "expect": "server_provenance"},
{"anchor": "gitea_mcp_server.py:21442", "expect": "def _check_mcp_runtimes_diagnostics"},
{"anchor": "gitea_mcp_server.py:21462", "expect": "\"ps\", \"-o\", \"pid,lstart,command\""},
{"anchor": "gitea_mcp_server.py:21506", "expect": "\"ps\", \"eww\""},
{"anchor": "gitea_mcp_server.py:15415", "expect": "def _is_client_managed_process"},
{"anchor": "gitea_mcp_server.py:15445", "expect": "def _provenance_mutation_block"},
{"anchor": "gitea_mcp_server.py:15453", "expect": "unsupported_manual_launch"},
{"anchor": "gitea_mcp_server.py:19004", "expect": "server_provenance"},
{"anchor": "gitea_mcp_server.py:21445", "expect": "def _check_mcp_runtimes_diagnostics"},
{"anchor": "gitea_mcp_server.py:21465", "expect": "\"ps\", \"-o\", \"pid,lstart,command\""},
{"anchor": "gitea_mcp_server.py:21509", "expect": "\"ps\", \"eww\""},
{"anchor": "gitea_config.py:1172", "expect": "RECOGNIZED_GITEA_ENV_KEYS"},
{"anchor": "gitea_config.py:1233", "expect": "GITEA_CLIENT_MANAGED"},
@@ -36,19 +36,19 @@
{"anchor": "gitea_config.py:974", "expect": "def resolve_token"},
{"anchor": "gitea_config.py:1015", "expect": "def keychain_auth"},
{"anchor": "gitea_config.py:294", "expect": "def _validate_identity_auth"},
{"anchor": "mcp_daemon_guard.py:440", "expect": "def assert_keychain_access_allowed"},
{"anchor": "gitea_mcp_server.py:19258", "expect": "def gitea_list_profiles"},
{"anchor": "gitea_mcp_server.py:19309", "expect": "gitea_config.resolve_token(p)"},
{"anchor": "gitea_mcp_server.py:19552", "expect": "def gitea_audit_config"},
{"anchor": "gitea_mcp_server.py:19574", "expect": "service_summaries(config)"},
{"anchor": "mcp_daemon_guard.py:519", "expect": "def assert_keychain_access_allowed"},
{"anchor": "gitea_mcp_server.py:19261", "expect": "def gitea_list_profiles"},
{"anchor": "gitea_mcp_server.py:19312", "expect": "gitea_config.resolve_token(p)"},
{"anchor": "gitea_mcp_server.py:19555", "expect": "def gitea_audit_config"},
{"anchor": "gitea_mcp_server.py:19577", "expect": "service_summaries(config)"},
{"anchor": "gitea_config.py:704", "expect": "def resolve_service"},
{"anchor": "gitea_config.py:837", "expect": "def service_summaries"},
{"anchor": "gitea_config.py:851", "expect": "_keychain_token(auth.get(\"id\"))"},
{"anchor": "gitea_mcp_server.py:17707", "expect": "\"jenkins-mcp\""},
{"anchor": "gitea_mcp_server.py:17713", "expect": "external-mcp"},
{"anchor": "gitea_mcp_server.py:17734", "expect": "\"glitchtip-mcp\""},
{"anchor": "gitea_mcp_server.py:17739", "expect": "external-mcp"},
{"anchor": "gitea_mcp_server.py:17710", "expect": "\"jenkins-mcp\""},
{"anchor": "gitea_mcp_server.py:17716", "expect": "external-mcp"},
{"anchor": "gitea_mcp_server.py:17737", "expect": "\"glitchtip-mcp\""},
{"anchor": "gitea_mcp_server.py:17742", "expect": "external-mcp"},
{"anchor": "mcp_discoverability.py:9", "expect": "EXPECTED_JENKINS_TOOLS"},
{"anchor": "mcp_discoverability.py:17", "expect": "EXPECTED_GLITCHTIP_TOOLS"},
@@ -59,13 +59,13 @@
{"anchor": "master_parity_gate.py:168", "expect": "def capture_startup_parity"},
{"anchor": "master_parity_gate.py:255", "expect": "mutation_safe"},
{"anchor": "gitea_mcp_server.py:19102", "expect": "def gitea_assess_master_parity"},
{"anchor": "gitea_mcp_server.py:19105", "expect": "def gitea_assess_master_parity"},
{"anchor": "gitea_mcp_server.py:190", "expect": "ACTIVE_WORKTREE_ENV"},
{"anchor": "gitea_mcp_server.py:191", "expect": "AUTHOR_WORKTREE_ENV"},
{"anchor": "gitea_mcp_server.py:2348", "expect": "/tmp/gitea_issue_lock.json"},
{"anchor": "gitea_mcp_server.py:10894", "expect": "def gitea_bootstrap_author_issue_worktree"},
{"anchor": "mcp_server.py:10", "expect": "/tmp/mcp_server_stderr.log"},
{"anchor": "gitea_mcp_server.py:193", "expect": "ACTIVE_WORKTREE_ENV"},
{"anchor": "gitea_mcp_server.py:194", "expect": "AUTHOR_WORKTREE_ENV"},
{"anchor": "gitea_mcp_server.py:2351", "expect": "/tmp/gitea_issue_lock.json"},
{"anchor": "gitea_mcp_server.py:10897", "expect": "def gitea_bootstrap_author_issue_worktree"},
{"anchor": "mcp_server.py:13", "expect": "/tmp/mcp_server_stderr.log"},
{"anchor": "issue_lock_store.py:26", "expect": "DEFAULT_LOCK_DIR"},
{"anchor": "issue_lock_store.py:83", "expect": "def session_pointer_path"},
@@ -75,6 +75,6 @@
{"anchor": "control_plane_db.py:380", "expect": "mode=0o700"},
{"anchor": "control_plane_db.py:386", "expect": "sqlite3.connect"},
{"anchor": "control_plane_db.py:1145", "expect": "os.getpid()"},
{"anchor": "gitea_mcp_server.py:12801", "expect": "owner_pid_alive"}
{"anchor": "gitea_mcp_server.py:12804", "expect": "owner_pid_alive"}
]
}
+29 -27
View File
@@ -27,7 +27,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:24721`, and its
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
@@ -75,15 +75,15 @@ authenticate the *caller*, not the *intent*.
| 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 literal `stdio` bind (`gitea_mcp_server.py:24721`) inside a closed allowlist (`mcp_daemon_guard.py:45`, `mcp_daemon_guard.py:174`); client-managed provenance (`gitea_mcp_server.py:15412`) or a refusal (`gitea_mcp_server.py:15450`); production transport before recovery-authorization mint (`irrecoverable_provenance.py:497`, consumed at `gitea_mcp_server.py:9129` and `gitea_mcp_server.py:9378`) | 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: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:440`). 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. |
| 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:10894`); the active workspace is process-global (`gitea_mcp_server.py:190`, `gitea_mcp_server.py:191`) | 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:12801`). A legacy global slot still exists at `gitea_mcp_server.py:2348`, 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: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:19102` | 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:19105` | Freshness defined against the deployed build identity, with an explicit fail-closed verdict when undeterminable. |
### What no boundary constrains
@@ -128,10 +128,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:19258`) reports each profile's credential status by calling
`resolve_token` on it (`gitea_mcp_server.py:19309`), and `gitea_audit_config`
(`gitea_mcp_server.py:19552`) reports service credential status through
`service_summaries` (`gitea_mcp_server.py:19574`).
(`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.
@@ -156,7 +156,7 @@ an attacker holding a token calls the API, not our tools.
| 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:440`) | B3, ADV5 | **Every other credential in this table except CR11 and CR12.** This is the aggregation point. |
| 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
@@ -176,16 +176,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:19258`) called from the **author** session reports
(`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:19309`). The author process does not merely *have access to* the
(`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:19552`) reports `MDCPS Jenkins: enabled, read-only, authenticated`.
That word `authenticated` is produced by `service_summaries` (`gitea_mcp_server.py:19574`,
(`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
@@ -193,8 +193,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:17707`, `gitea_mcp_server.py:17713`, `gitea_mcp_server.py:17734`,
`gitea_mcp_server.py:17739`) with their own expected tool sets (`mcp_discoverability.py:9`,
(`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
@@ -205,8 +205,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:21462` and
`gitea_mcp_server.py:21506`, reached from `gitea_mcp_server.py:21442`.
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,
@@ -215,13 +215,15 @@ 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:19001`),
derived from environment inspection (`gitea_mcp_server.py:15412`) with the recognized-key
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 stdio contract that underwrites it is stated as a property of the
server itself (`mcp_server.py:4`).
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
@@ -253,7 +255,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:19309` and `gitea_mcp_server.py:19574` must report
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
@@ -330,11 +332,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:15412`) 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: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 CR1CR10 via CR13, with no additional prompt — the daemon is already sanctioned, so `assert_keychain_access_allowed` (`mcp_daemon_guard.py:440`) returns immediately. | Nothing. |
| B3 | Every credential in CR1CR10 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:10894`), plus the shared stderr path at `mcp_server.py:10`. | 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: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. |