feat(mcp): gate Connected-but-unattached MCP namespaces (Closes #708) #967
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user