Compare commits

...
Author SHA1 Message Date
sysadmin e423dd5870 fix(mcp): remediate B8, B6/B11, and B15 break-glass restart blockers (#664)
- B8: Correct redaction boundary for GITEA_TOKEN= and URI userinfo without destroying adjacent audit evidence or benign sec- text
- B6/B11: Remove false restart execution claims from default executor when GITEA_SANCTIONED_RESTART_HOOK is non-empty
- B15: Document deployable production grant set (runtime.break_glass_restart and gitea.issue.create) for prgs-controller
- Preserve B13, B1, B14 and previously accepted corrections
2026-07-29 01:23:10 -04:00
sysadminandClaude Opus 4.8 c67f39b40e fix(#664): remediate PR #908 review #641 blockers B13, B1, B14, B6/B11, and B8
Register runtime.break_glass_restart in the multi-service operation normalizer
and enforce it through the real profile gate. Authorize only the exact trusted
prgs-controller profile plus that capability; remove substring controller
authority so declared reconciler roles and cleanup_merged_pr_branch semantics
are preserved. Route non-dry-run apply through a canonical injectable executor
delegate with truthful execution flags. Correct under/over-redaction for
credentials while preserving benign sec- text.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 23:33:25 -04:00
sysadmin 4463a300ba fix(#664): remediate break-glass restart workflow blockers B1, B6/B11, B9, B10, B8, and B12 2026-07-28 22:44:43 -04:00
sysadmin 75794609d1 fix(mcp): remediate break-glass restart authorization and audit findings (#664) 2026-07-28 21:45:31 -04:00
jcwalker3 da3294fbe5 Merge branch 'master' into feat/issue-664-break-glass-restart 2026-07-28 08:39:10 -05:00
sysadmin 9b80e75ca3 Merge pull request 'docs(remote-mcp): threat model, trust boundaries, and decomposition ruling (Closes #956)' (#965) from docs/issue-956-remote-mcp-threat-model into master 2026-07-28 08:19:45 -05:00
sysadminandClaude Opus 4.8 b3de9c941c docs(remote-mcp): threat model, trust boundaries, and decomposition ruling (Closes #956)
#930 inventoried stdio coupling; nothing stated what the adversary is, what
each boundary protects, or why one process may hold credentials for several
services. This adds that document as child 2 of epic #929.

Adds docs/remote-mcp/threat-model.md covering 10 assets, the 5 adversaries
#956 names, 9 trust boundaries, the data flows between them, a 14-entry
per-boundary credential inventory, and an explicit decomposition ruling.

Findings established from live native evidence at this commit:

- Four prgs roles (reviewer, merger, reconciler, controller) resolve to one
  Gitea account, so separation of duty between approving and landing is
  enforced only by which process a call reaches. The mdcps tenant has no role
  separation at all: author, reviewer, and merger share one account.
- Any one role process can resolve every other role's credential.
  gitea_list_profiles reports "credentials present" for other roles because it
  calls resolve_token on each one.
- The Gitea server reads Jenkins and GlitchTip secrets out of the keychain to
  produce the "authenticated" word in gitea_audit_config's service summaries.
- Jenkins and GlitchTip were already decomposed into separate MCP servers; the
  credential references were left behind in the Gitea configuration.

Ruling D1 forbids a single integration process from holding credentials for
unrelated services, with one time-boxed dual-run exception for the local fleet
that expires with #939. D2 requires separation of duty to be credential-backed,
D3 scopes credential resolution to the request principal, and D4 gives
coordination state its own authority. Every #929 child from 2 through 10 is
mapped to the boundary it implements.

Anchors are enforced rather than asserted. #930's inventory anchors into
gitea_mcp_server.py had already drifted between 7bf4f125 and aad5c8b4 with
nothing detecting it, so this change ships the guard that was missing:
docs/remote-mcp/threat-model-anchors.json declares all 58 anchors with the
substring each must contain, and tests/test_issue_956_threat_model.py fails if
any anchor does not resolve, if the document cites an anchor the fixture does
not cover, or if the structural obligations regress.

Documentation only. No server behavior changes.

Tests:
- tests/test_issue_956_threat_model.py: 17 passed.
- Four sabotage probes confirm the validator is not passing vacuously
  (shifted anchor, undeclared citation, broken count tally, and a blanked
  boundary owner). The last two probes exposed real weaknesses in the checks
  themselves, which were fixed: the section slice now stops at the next
  heading, and boundary ownership is read only from mapping table rows.
- Docs-sensitive sweep (17 modules referencing docs/): 559 passed.
- Full suite: 30 failed, 5799 passed, 6 skipped. All 30 reproduce on a clean
  base worktree at aad5c8b4; branch failures are a strict subset of base
  failures. No production file is modified by this change.

Closes #956

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 04:41:26 -04:00
sysadmin aad5c8b423 Merge pull request 'fix(author): unify the bootstrap and lock_issue issue-lock contract (Closes #953)' (#954) from fix/issue-953-bootstrap-lock-provenance into master
Merges PR #954 at approved head b4c9f55890.

Approval: review 633 APPROVE at b4c9f55890.
Base: master at 82d71b7702.

Closes #953
2026-07-28 02:21:11 -05:00
sysadmin c1ecadce8e feat(mcp): implement emergency break-glass MCP restart workflow (#664) 2026-07-25 17:06:38 -04:00
10 changed files with 2640 additions and 51 deletions
+14
View File
@@ -144,6 +144,19 @@ tool argument expresses caller intent and cannot be self-asserted by a worker
session. `break_glass_requested` and `break_glass_authorized` are both reported, session. `break_glass_requested` and `break_glass_authorized` are both reported,
so a bypass is never silent. so a bypass is never silent.
### Break-glass Restart Workflow (`gitea_break_glass_restart`, #664)
The dedicated MCP tool `gitea_break_glass_restart` provides the privileged emergency break-glass restart workflow when graceful drain cannot complete:
- **Authorization (#664 AC1 / B1 / B13 / B15)**: Requires the exact trusted profile `prgs-controller` **and** explicit `runtime.break_glass_restart` and `gitea.issue.create` grants enforced by the real production operation gate (no `gitea.read` fallback). Incident creation is mandatory prior to execution (`gitea.issue.create`), so the deployable production policy for `prgs-controller` includes `allowed_operations`: `["gitea.read", "gitea.pr.close", "gitea.pr.comment", "gitea.issue.comment", "gitea.issue.create", "runtime.break_glass_restart", "gitea.branch.delete", "gitea.decision_lock.irrecoverable_recovery"]`. Ordinary roles, non-controller reconcilers, lookalike profile names (`fake-controller`, …), and env vars cannot authorize. A narrow break-glass capability does **not** redefine the profile's declared global role. Updating a live running `prgs-controller` profile in production requires an operator configuration update and daemon reload post-merge.
- **Required Parameters (#664 AC2)**:
- `reason`: Mandatory non-empty string (min 10 characters).
- `confirmation`: Must equal exactly `"I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION"`.
- `impact_ack`: Must be `True`.
- **Automatic Incident Creation (#664 AC3)**: Creates a Gitea incident issue (`[INCIDENT] [REQUESTED] Break-glass MCP restart invoked by ...`) detailing the reason, timestamp, disrupted sessions, and linking `#652 #653 #655 #630 #658 #662 #664`.
- **Immutable Append-Only Audit Entry**: Records immutable pre-execution (REQUESTED) and post-execution (SUCCEEDED/FAILED) audit log entries with correlation identifiers.
- **Mandatory Reconciliation (#664 AC4)**: Sets `reconciliation_required=True` requiring post-restart reconciliation via `gitea_reconcile_after_restart` (#662).
### Fail closed on apply ### Fail closed on apply
A missing, malformed, expired, unclean, tampered, or fingerprint-stale drain A missing, malformed, expired, unclean, tampered, or fingerprint-stale drain
@@ -160,3 +173,4 @@ profiles are operational metadata only.
A representative dry-run report is in A representative dry-run report is in
[`mcp-restart-impact-sample.json`](./mcp-restart-impact-sample.json). [`mcp-restart-impact-sample.json`](./mcp-restart-impact-sample.json).
+80
View File
@@ -0,0 +1,80 @@
{
"_comment": [
"Machine-checkable anchor table for docs/remote-mcp/threat-model.md (#956).",
"Every file:line anchor cited in the threat model must appear here, and the",
"source line at that anchor must contain the 'expect' substring.",
"tests/test_issue_956_threat_model.py enforces both directions, so a refactor",
"that shifts a line number fails the suite instead of silently rotting the",
"document. #930's inventory had no such guard and its gitea_mcp_server.py",
"anchors drifted between 7bf4f125 and aad5c8b4."
],
"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": "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: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_config.py:1172", "expect": "RECOGNIZED_GITEA_ENV_KEYS"},
{"anchor": "gitea_config.py:1233", "expect": "GITEA_CLIENT_MANAGED"},
{"anchor": "gitea_config.py:54", "expect": "ENV_PROFILE = \"GITEA_MCP_PROFILE\""},
{"anchor": "gitea_config.py:97", "expect": "_REVIEW_MERGE_OPS"},
{"anchor": "gitea_config.py:499", "expect": "repository authorization scope"},
{"anchor": "gitea_config.py:956", "expect": "def _keychain_token"},
{"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": "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": "mcp_discoverability.py:9", "expect": "EXPECTED_JENKINS_TOOLS"},
{"anchor": "mcp_discoverability.py:17", "expect": "EXPECTED_GLITCHTIP_TOOLS"},
{"anchor": "sentry_incident_bridge.py:36", "expect": "SENTRY_AUTH_TOKEN"},
{"anchor": "sentry_incident_bridge.py:190", "expect": "def resolve_token"},
{"anchor": "sentry_incident_bridge.py:289", "expect": "Authorization"},
{"anchor": "sentry_observability.py:55", "expect": "SENTRY_DSN"},
{"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: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": "issue_lock_store.py:26", "expect": "DEFAULT_LOCK_DIR"},
{"anchor": "issue_lock_store.py:83", "expect": "def session_pointer_path"},
{"anchor": "issue_lock_store.py:98", "expect": "def is_process_alive"},
{"anchor": "mcp_session_state.py:27", "expect": "DEFAULT_STATE_DIR"},
{"anchor": "control_plane_db.py:47", "expect": "DEFAULT_DB_PATH"},
{"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"}
]
}
+403
View File
@@ -0,0 +1,403 @@
# 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:24721`, 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 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. |
| 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. |
| 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. |
| 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. |
### What no boundary constrains
None of B1B9 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: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`).
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:440`) | 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:19258`) 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
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`,
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: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`,
`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:21462` and
`gitea_mcp_server.py:21506`, reached from `gitea_mcp_server.py:21442`.
**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:19001`),
derived from environment inspection (`gitea_mcp_server.py:15412`) 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`).
## 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:19309` and `gitea_mcp_server.py:19574` 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:15412`) 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. |
| 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. |
| 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 D1D4**.
| 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.
+99 -10
View File
@@ -27,13 +27,73 @@ ALLOWED = "allowed"
BLOCKED = "blocked" BLOCKED = "blocked"
FAILED = "failed" FAILED = "failed"
SUCCEEDED = "succeeded" SUCCEEDED = "succeeded"
REQUESTED = "requested"
ACCEPTED = "accepted"
PENDING = "pending"
REDACTED = "[REDACTED]" REDACTED = "[REDACTED]"
# A dict key containing any of these (case-insensitive) has its value redacted. # A dict key containing any of these (case-insensitive) has its value redacted.
_SECRET_KEY_HINTS = ("token", "password", "secret", "authorization", "auth") _SECRET_KEY_HINTS = (
"token",
"password",
"passwd",
"pwd",
"secret",
"authorization",
"auth",
"api_key",
"apikey",
"access_key",
"private_key",
"client_secret",
"credential",
)
# A string value starting with one of these has the following run redacted. # A string value starting with one of these has the following run redacted.
_SECRET_VALUE_PREFIXES = ("token ", "Basic ", "Bearer ") # Space-terminated scheme prefixes only. Colon forms (``token:`` / ``api_key:``)
# and ``Authorization: Bearer …`` are handled by ``_ASSIGNMENT_SECRET_PATTERN``
# so policy/docs text that merely *names* a scheme is not itself flagged as a
# live secret by console detectors.
_SECRET_VALUE_PREFIXES = (
"token ",
"Basic ",
"Bearer ",
)
# Bare token-shaped values only — never a broad ``sec-`` prefix that erases
# ordinary words (#664 B8 over-redaction).
_BARE_SECRET_PATTERN = re.compile(
r'(?i)\b(?:'
r'ghp_[A-Za-z0-9_]{16,}'
r'|gho_[A-Za-z0-9_]{16,}'
r'|ghu_[A-Za-z0-9_]{16,}'
r'|ghs_[A-Za-z0-9_]{16,}'
r'|ghr_[A-Za-z0-9_]{16,}'
r'|sk-live-[A-Za-z0-9_-]{16,}'
r'|sk-proj-[A-Za-z0-9_-]{16,}'
r'|sk-[A-Za-z0-9_-]{20,}'
r'|glpat-[A-Za-z0-9_-]{16,}'
r')\b'
)
# Key/value credentials embedded in free text (password=..., api_key: ..., GITEA_TOKEN=..., etc.).
# Group 1 captures the key name (e.g. GITEA_TOKEN, password, api_key).
# Group 2 captures delimiter/whitespace (=, : ).
# Group 3 captures the secret value, stopping at whitespace or non-secret delimiters (&, ;, ,, quotes, closing brackets).
_ASSIGNMENT_SECRET_PATTERN = re.compile(
r'(?i)\b([A-Za-z0-9_]*?(?:token|password|passwd|pwd|secret|api[_-]?key|access[_-]?key|'
r'client[_-]?secret|private[_-]?key|authorization|credential))\b(\s*[:=]\s*)('
r'"[^"]*"|\'[^\']*\'|'
r'(?:Bearer|Basic|Token)\s+[^\s;&,"\'\)\}\]\>]+|'
r'[^\s;&,"\'\)\}\]\>]+'
r')'
)
# Connection-string style credentials: Password=...; User ID=...; etc.
_CONN_STRING_SECRET_PATTERN = re.compile(
r'(?i)\b((?:password|pwd|user\s*id|uid|username|account)\s*=\s*)([^\s;\'"]+)'
)
# Known synthetic test-only domains/hostnames to preserve # Known synthetic test-only domains/hostnames to preserve
_SYNTHETIC_HOSTS = { _SYNTHETIC_HOSTS = {
@@ -59,7 +119,8 @@ def redact_urls(text: str) -> str:
if not isinstance(text, str) or not text: if not isinstance(text, str) or not text:
return text return text
url_pattern = re.compile(r'(https?://[^\s)>\]}]+)', re.IGNORECASE) # Match any URI scheme (http, https, postgres, mysql, mongodb, redis, etc.)
url_pattern = re.compile(r'([a-z0-9\+\.\-]+://[^\s)>\]}]+)', re.IGNORECASE)
def replace_url(match): def replace_url(match):
url_str = match.group(1) url_str = match.group(1)
@@ -73,11 +134,11 @@ def redact_urls(text: str) -> str:
is_synthetic = True is_synthetic = True
break break
if is_synthetic: if is_synthetic or (parsed.username or parsed.password) or parsed.scheme.lower() not in ("http", "https"):
# Rebuild synthetic URL to redact any credentials or query secrets # Rebuild URL to redact any credentials or query secrets
new_netloc = parsed.netloc new_netloc = parsed.netloc
if parsed.username or parsed.password: if parsed.username or parsed.password:
netloc_clean = parsed.hostname netloc_clean = parsed.hostname or ""
if parsed.port: if parsed.port:
netloc_clean = f"{netloc_clean}:{parsed.port}" netloc_clean = f"{netloc_clean}:{parsed.port}"
new_netloc = f"[REDACTED_USER]:[REDACTED_PASS]@{netloc_clean}" new_netloc = f"[REDACTED_USER]:[REDACTED_PASS]@{netloc_clean}"
@@ -114,23 +175,51 @@ def redact_urls(text: str) -> str:
return out return out
def _mask_assignment(match: re.Match) -> str:
"""Keep the key and separator; replace only the secret value."""
val = match.group(3)
if val.startswith(REDACTED) or val.startswith("%5BREDACTED") or val.startswith("[REDACTED"):
return f"{match.group(1)}{match.group(2)}{val}"
return f"{match.group(1)}{match.group(2)}{REDACTED}"
def _mask_conn_secret(match: re.Match) -> str:
"""Keep the connection-string key; replace only the credential value."""
val = match.group(2)
if val.startswith(REDACTED) or val.startswith("%5BREDACTED") or val.startswith("[REDACTED"):
return f"{match.group(1)}{val}"
return f"{match.group(1)}{REDACTED}"
def _redact_str(text): def _redact_str(text):
"""Redact anything that looks like an Authorization credential or raw URL in *text*.""" """Redact credentials, bare token shapes, and raw URLs in *text* (#664 B8).
Covers key/value credentials, authorization/bearer material, bare
token-shaped values, connection-string credentials, and secrets embedded
in larger sentences. Deliberately does **not** erase ordinary words that
merely begin with a broad ``sec-`` prefix.
"""
if not isinstance(text, str) or not text: if not isinstance(text, str) or not text:
return text return text
out = text out = redact_urls(text)
out = _BARE_SECRET_PATTERN.sub(REDACTED, out)
out = _ASSIGNMENT_SECRET_PATTERN.sub(_mask_assignment, out)
out = _CONN_STRING_SECRET_PATTERN.sub(_mask_conn_secret, out)
out_lower = out.lower()
for prefix in _SECRET_VALUE_PREFIXES: for prefix in _SECRET_VALUE_PREFIXES:
prefix_lower = prefix.lower()
idx = 0 idx = 0
while True: while True:
i = out.find(prefix, idx) i = out_lower.find(prefix_lower, idx)
if i == -1: if i == -1:
break break
j = i + len(prefix) j = i + len(prefix)
while j < len(out) and not out[j].isspace(): while j < len(out) and not out[j].isspace():
j += 1 j += 1
out = out[:i] + prefix + REDACTED + out[j:] out = out[:i] + prefix + REDACTED + out[j:]
out_lower = out.lower()
idx = i + len(prefix) + len(REDACTED) idx = i + len(prefix) + len(REDACTED)
return redact_urls(out) return out
def redact(value): def redact(value):
+32 -3
View File
@@ -97,6 +97,26 @@ GITEA_OPERATION_ALIASES = {
_REVIEW_MERGE_OPS = frozenset({"gitea.pr.approve", "gitea.pr.merge"}) _REVIEW_MERGE_OPS = frozenset({"gitea.pr.approve", "gitea.pr.merge"})
_AUTHOR_ONLY_OPS = frozenset({"gitea.pr.create", "gitea.branch.push"}) _AUTHOR_ONLY_OPS = frozenset({"gitea.pr.create", "gitea.branch.push"})
# First-class operation services that may appear in multi-service profile
# allowlists. ``runtime.*`` is the control-plane capability namespace used by
# non-Gitea MCP tools such as ``runtime.break_glass_restart`` (#664 B13).
# Unknown foreign prefixes (e.g. ``jenkins.*`` under service=gitea) still fail
# closed — they are not registered here.
KNOWN_OPERATION_SERVICES = frozenset({"gitea", "runtime"})
def service_for_operation(op, default="gitea"):
"""Return the registered service prefix for a fully-qualified *op*.
Unqualified names and unknown prefixes fall back to *default* so callers
keep the historical Gitea-centric gate behaviour.
"""
if isinstance(op, str) and "." in op:
prefix = op.split(".", 1)[0]
if prefix in KNOWN_OPERATION_SERVICES:
return prefix
return default
def normalize_operation(op, service="gitea"): def normalize_operation(op, service="gitea"):
"""Return the canonical namespaced name for *op*, or fail closed (#106). """Return the canonical namespaced name for *op*, or fail closed (#106).
@@ -133,6 +153,12 @@ def check_operation(op, allowed, forbidden=(), service="gitea"):
Reasons: ``allowed``, ``invalid-operation``, ``invalid-forbidden-entry``, Reasons: ``allowed``, ``invalid-operation``, ``invalid-forbidden-entry``,
``forbidden``, ``no-allowed-operations``, ``not-allowed``. ``forbidden``, ``no-allowed-operations``, ``not-allowed``.
Multi-service profile allowlists (#664 B13): each allow/forbid entry is
normalized with its own registered service prefix (``gitea.*`` or
``runtime.*``) so a gate defaulting to service=gitea can still enforce an
exact ``runtime.break_glass_restart`` grant. Unknown / misspelled
operations and foreign service prefixes remain fail-closed.
Fail-closed rules: Fail-closed rules:
- an *op* that cannot be normalized is denied (``invalid-operation``) - an *op* that cannot be normalized is denied (``invalid-operation``)
- a forbidden entry that cannot be normalized denies the request - a forbidden entry that cannot be normalized denies the request
@@ -143,14 +169,16 @@ def check_operation(op, allowed, forbidden=(), service="gitea"):
- ``forbidden`` always overrides ``allowed`` - ``forbidden`` always overrides ``allowed``
- an empty or missing allowed list denies everything - an empty or missing allowed list denies everything
""" """
op_service = service_for_operation(op, default=service)
try: try:
op_n = normalize_operation(op, service) op_n = normalize_operation(op, op_service)
except ConfigError: except ConfigError:
return (False, "invalid-operation") return (False, "invalid-operation")
forbidden_n = set() forbidden_n = set()
for entry in (forbidden or ()): for entry in (forbidden or ()):
try: try:
forbidden_n.add(normalize_operation(entry, service)) entry_service = service_for_operation(entry, default=service)
forbidden_n.add(normalize_operation(entry, entry_service))
except ConfigError: except ConfigError:
return (False, "invalid-forbidden-entry") return (False, "invalid-forbidden-entry")
if op_n in forbidden_n: if op_n in forbidden_n:
@@ -160,7 +188,8 @@ def check_operation(op, allowed, forbidden=(), service="gitea"):
allowed_n = set() allowed_n = set()
for entry in allowed: for entry in allowed:
try: try:
allowed_n.add(normalize_operation(entry, service)) entry_service = service_for_operation(entry, default=service)
allowed_n.add(normalize_operation(entry, entry_service))
except ConfigError: except ConfigError:
continue continue
if op_n in allowed_n: if op_n in allowed_n:
+690 -35
View File
@@ -233,28 +233,50 @@ def _effective_workspace_role() -> str:
) )
# Exact production profile → role mapping used only when no declared role is
# present. Substring lookalikes (fake-controller, not-controller, …) never
# match (#664 B1/B14).
_EXACT_PROFILE_ROLE_NAMES = {
"prgs-controller": "controller",
"prgs-reconciler": "reconciler",
"prgs-author": "author",
"prgs-reviewer": "reviewer",
"prgs-merger": "merger",
"mdcps-author": "author",
"mdcps-reviewer": "reviewer",
"mdcps-merger": "merger",
"controller": "controller",
"reconciler": "reconciler",
"author": "author",
"reviewer": "reviewer",
"merger": "merger",
}
# Exact trusted profile that may hold break-glass (#664 B1). Capability
# ``runtime.break_glass_restart`` is still required and enforced by the real
# operation gate; this set only rejects lookalike profile names.
TRUSTED_BREAK_GLASS_PROFILES = frozenset({"prgs-controller"})
def _profile_role_kind(profile: dict) -> str: def _profile_role_kind(profile: dict) -> str:
"""Resolve a profile's declared role before inferring from permissions. """Resolve a profile's declared role before inferring from permissions.
Declared ``role`` / ``role_kind`` always wins so a controller profile is Declared ``role`` / ``role_kind`` always wins so a controller profile is
never reclassified as reconciler from permission inference (#840). never reclassified as reconciler from permission inference (#840). Exact
match only profile-name / role *substrings* never grant controller (or
any) authority (#664 B1/B14). Lookalikes such as ``fake-controller``,
``not-controller``, or ``xcontrollerx`` do not become controller.
""" """
role = (profile.get("role") or profile.get("role_kind") or "").strip().lower() role = (profile.get("role") or profile.get("role_kind") or "").strip().lower()
if role: if role:
# Normalize aliases / case. # Exact aliases only; never ``"control" in role`` (matches
if "control" in role: # "not-controller" / "control-plane").
if role in ("controller", "control"):
return "controller" return "controller"
return role return role
profile_name = (profile.get("profile_name") or "").strip().lower() profile_name = (profile.get("profile_name") or "").strip().lower()
for candidate in ( if profile_name in _EXACT_PROFILE_ROLE_NAMES:
"controller", return _EXACT_PROFILE_ROLE_NAMES[profile_name]
"reconciler",
"merger",
"reviewer",
"author",
):
if candidate in profile_name:
return candidate
return _role_kind( return _role_kind(
profile.get("allowed_operations") or [], profile.get("allowed_operations") or [],
profile.get("forbidden_operations") or [], profile.get("forbidden_operations") or [],
@@ -7121,35 +7143,17 @@ def terminal_review_hard_stop_reasons(
] ]
# Patterns scrubbed from any surfaced error text so a credential can never leak.
_SECRET_PREFIXES = ("token ", "Basic ")
def _redact(text: str) -> str: def _redact(text: str) -> str:
"""Strip anything that looks like an Authorization credential or raw URL from *text*. """Strip credentials, bare token shapes, and raw URLs from *text* (#664 B8).
Errors raised by ``api_request`` echo the server response body, not the Defers to the canonical :mod:`gitea_audit` redactor so MCP tool results,
request headers, so a token should never appear this is defence in depth incidents, audits, and delegated error surfaces share one boundary.
so a future change can't leak ``token …`` / ``Basic …`` material into a
tool result or log line.
""" """
if not text: if not text:
return text return text
out = text
for prefix in _SECRET_PREFIXES:
idx = 0
while True:
i = out.find(prefix, idx)
if i == -1:
break
j = i + len(prefix)
while j < len(out) and not out[j].isspace():
j += 1
out = out[:i] + prefix + "[REDACTED]" + out[j:]
idx = i + len(prefix) + len("[REDACTED]")
# Redact raw URLs, query secrets, hostnames, etc.
import gitea_audit import gitea_audit
return gitea_audit.redact_urls(out) redacted = gitea_audit._redact_str(str(text))
return redacted if isinstance(redacted, str) else str(redacted)
# Review states that carry a submitted verdict. Gitea also emits PENDING # Review states that carry a submitted verdict. Gitea also emits PENDING
@@ -23922,6 +23926,657 @@ def gitea_request_mcp_restart(
return payload return payload
BREAK_GLASS_CONFIRMATION_PHRASE = "I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION"
# Test-injectable canonical break-glass executor/delegate (#664 B6/B11).
# Production default never signals the live MCP cohort.
_break_glass_restart_executor = None
def _default_break_glass_restart_executor(request: dict) -> dict:
"""Canonical non-dry-run break-glass executor/delegate (#664 B6/B11).
Never kills, signals, or restarts the live MCP cohort in-process.
An environment string alone (``GITEA_SANCTIONED_RESTART_HOOK``) does not
prove restart execution without an active confirmed delegate handoff.
"""
hook = (os.environ.get("GITEA_SANCTIONED_RESTART_HOOK") or "").strip()
if hook:
return {
"success": False,
"apply_supported": True,
"apply_authorized": True,
"restart_performed": False,
"break_glass_executed": False,
"execution_mode": "accepted_not_executed",
"host_hook_configured": True,
"reasons": [
"sanctioned host restart hook reference is configured, but environment text "
"cannot prove execution without a confirmed delegate handoff (fail closed) (#664 B6/B11)"
],
}
return {
"success": False,
"apply_supported": False,
"apply_authorized": False,
"restart_performed": False,
"break_glass_executed": False,
"execution_mode": "unsupported",
"reasons": [
"break-glass apply is unsupported: no sanctioned host restart "
"executor is configured (#664)"
],
}
def _run_break_glass_restart_executor(request: dict) -> dict:
"""Invoke the installed or default break-glass executor (test-injectable)."""
executor = _break_glass_restart_executor or _default_break_glass_restart_executor
result = executor(request)
if not isinstance(result, dict):
return {
"success": False,
"apply_supported": True,
"apply_authorized": False,
"restart_performed": False,
"break_glass_executed": False,
"reasons": ["break-glass executor returned a non-dict result (fail closed)"],
}
return result
@mcp.tool()
def gitea_break_glass_restart(
reason: str,
confirmation: str,
impact_ack: bool = False,
restart_class: str = "full_mcp_restart",
create_incident_issue: bool = True,
dry_run: bool = False,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Privileged emergency break-glass MCP restart workflow (#664).
Break-glass restart permits emergency recovery when graceful drain cannot
complete. It requires:
1. Exact ``runtime.break_glass_restart`` capability on the trusted
``prgs-controller`` profile (ordinary roles, lookalike names, env vars,
and non-controller reconcilers fail closed).
2. Explicit non-empty reason (minimum 10 characters).
3. Exact confirmation string matching 'I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION'.
4. Mandatory impact acknowledgement (impact_ack=True).
5. Immutable append-only audit entry recorded prior to execution and after terminal completion.
6. Automatic incident record created on Gitea prior to execution.
7. Truthful execution reporting via the canonical executor/delegate and
mandatory post-restart reconciliation (#662).
"""
# B13: real production gate for the exact canonical operation — never
# fall back to gitea.read and never stub this gate in production.
capability_block = _profile_operation_gate("runtime.break_glass_restart")
if capability_block:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": capability_block,
"permission_report": _permission_block_report("runtime.break_glass_restart"),
"blocker_kind": "permission_denied",
})
h, o, r = _resolve(remote, host, org, repo)
profile = get_profile()
active_role = _profile_role_kind(profile)
profile_name = (
(profile.get("profile_name") or profile.get("execution_profile") or "")
.strip()
)
break_glass_env_auth = bool(
(os.environ.get("GITEA_BREAKGLASS_RESTART_AUTHORIZATION") or "").strip()
)
# Env is never an authorization channel for this endpoint (B2/B1).
_ = break_glass_env_auth
# B1: exact trusted profile only — not role substring, not lookalike names.
if profile_name not in TRUSTED_BREAK_GLASS_PROFILES:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"active_role": active_role,
"profile_name": profile_name or None,
"reasons": [
f"profile '{profile_name or '(unset)'}' is not the trusted "
f"break-glass profile {sorted(TRUSTED_BREAK_GLASS_PROFILES)}; "
"lookalike names, ordinary roles, env vars, and non-controller "
"reconcilers cannot authorize break-glass (#664 AC1/B1)"
],
"blocker_kind": "role_authorization",
})
# 2. Required fields and redaction (B8)
raw_reason = (reason or "").strip()
clean_reason = _redact(raw_reason)
if not raw_reason or len(raw_reason) < 10:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
"reason is required and must be at least 10 characters long (#664 AC2)"
],
"blocker_kind": "missing_required_fields",
})
clean_confirmation = (confirmation or "").strip()
if clean_confirmation != BREAK_GLASS_CONFIRMATION_PHRASE:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
f"confirmation string mismatch; must equal exactly '{BREAK_GLASS_CONFIRMATION_PHRASE}' (#664 AC2)"
],
"blocker_kind": "confirmation_mismatch",
})
if not impact_ack:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
"impact_ack must be True to acknowledge disruption of in-flight sessions (#664 AC2)"
],
"blocker_kind": "impact_ack_required",
})
# Gate incident issue creation on gitea.issue.create permission (B3)
if create_incident_issue:
create_block = _profile_operation_gate("gitea.issue.create")
if create_block:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": create_block,
"permission_report": _permission_block_report("gitea.issue.create"),
"blocker_kind": "permission_denied",
})
# Evaluate impact / disrupted sessions
impact_result = gitea_request_mcp_restart(
remote=remote,
host=host,
org=org,
repo=repo,
dry_run=True,
restart_class=restart_class,
request_break_glass=True,
)
disrupted_sessions = list(impact_result.get("affected_sessions") or [])
disrupted_count = len(disrupted_sessions)
identity = _authenticated_username(h) or profile.get("username") or "unknown"
now_iso = datetime.now(timezone.utc).isoformat()
ns_ctx = _resolve_namespace_mutation_context(worktree_path)
mcp_namespace = ns_ctx.get("mcp_namespace") or profile.get("profile_name") or "gitea-controller"
correlation_id = f"bg-{uuid.uuid4().hex[:12]}"
audit_payload = gitea_audit.redact({
"event": "break_glass_mcp_restart",
"correlation_id": correlation_id,
"actor": identity,
"role": active_role,
"mcp_namespace": mcp_namespace,
"timestamp": now_iso,
"reason": clean_reason,
"confirmation": clean_confirmation,
"restart_class": restart_class,
"disrupted_sessions_count": disrupted_count,
"disrupted_sessions": [
s.get("session_id") if isinstance(s, dict) else str(s)
for s in disrupted_sessions
],
"dry_run": dry_run,
"remote": remote,
"org": o,
"repo": r,
"env_auth_present": break_glass_env_auth,
})
# Dry-run handling (B7: no durable mutation)
if dry_run:
return gitea_audit.redact({
"success": True,
"performed": False,
"dry_run": True,
"break_glass_executed": False,
"would_execute": True,
"correlation_id": correlation_id,
"actor": identity,
"role": active_role,
"mcp_namespace": mcp_namespace,
"restart_class": restart_class,
"reason": clean_reason,
"confirmation": clean_confirmation,
"disrupted_sessions_count": disrupted_count,
"disrupted_sessions": disrupted_sessions,
"audit_record": audit_payload,
"saved_audit": None,
"incident_issue": None,
"reconciliation_required": True,
"reconciliation_tool": "gitea_reconcile_after_restart",
"follow_up_issue_required": True,
"cross_references": ["#652", "#653", "#655", "#630", "#658", "#662", "#664"],
"reasons": ["break-glass restart dry-run evaluated successfully"],
})
# Fail closed if create_incident_issue is False on real execution (B5)
if not create_incident_issue:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
"create_incident_issue=False is forbidden on real break-glass execution; "
"pre-execution incident creation is mandatory (#664 AC3)"
],
"blocker_kind": "incident_creation_required",
})
# B9: Fail closed if audit backend is disabled
if not gitea_audit.audit_enabled():
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
"audit recording is disabled or unavailable; break-glass restart requires an enabled audit backend (#664 AC3)"
],
"blocker_kind": "audit_recording_failed",
})
# Pre-execution recording: Audit record in REQUESTED state (B4, B10)
pre_audit_event = gitea_audit.build_event(
action="break_glass_mcp_restart_requested",
result=gitea_audit.REQUESTED,
remote=remote,
server=(gitea_url(h, "").rstrip("/") if h else None),
repository=r,
profile_name=profile.get("profile_name", "unknown"),
audit_label=profile.get("audit_label", "unknown"),
authenticated_username=identity,
reason=clean_reason,
mcp_namespace=mcp_namespace,
task_role=active_role,
operation="break_glass_mcp_restart",
now=now_iso,
request_metadata={
"correlation_id": correlation_id,
"confirmation": clean_confirmation,
"restart_class": restart_class,
"disrupted_sessions_count": disrupted_count,
"disrupted_sessions": [
s.get("session_id") if isinstance(s, dict) else str(s)
for s in disrupted_sessions
],
},
)
audit_write_success = gitea_audit.write_event(pre_audit_event)
if not audit_write_success:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
"failed to persist required pre-execution audit event (#664 AC3)"
],
"blocker_kind": "audit_recording_failed",
})
# Pre-execution recording: Gitea Incident Issue (B5, B8)
issue_title = _redact(f"[INCIDENT] [REQUESTED] Break-glass MCP restart invoked by {identity} ({correlation_id})")
issue_body = _redact(
f"## Break-glass MCP restart incident report (#664)\n\n"
f"- **Correlation ID**: `{correlation_id}`\n"
f"- **Invoked by**: `{identity}` (role: `{active_role}`, namespace: `{mcp_namespace}`)\n"
f"- **Timestamp**: `{now_iso}`\n"
f"- **Reason**: {clean_reason}\n"
f"- **Confirmation**: `{clean_confirmation}`\n"
f"- **Disrupted Sessions Count**: `{disrupted_count}`\n\n"
f"### Mandatory Post-Restart Reconciliation (#662)\n"
f"Post-restart reconciliation must be executed via `gitea_reconcile_after_restart` "
f"to clean up orphaned leases, inspect worktree integrity, and handle disrupted work.\n\n"
f"### Cross-references\n"
f"Ref #652 #653 #655 #630 #658 #662 #664\n"
)
incident_issue_result = None
try:
incident_issue_result = api_request(
"POST",
f"{repo_api_url(h, o, r)}/issues",
_auth(h),
{
"title": issue_title,
"body": issue_body,
"labels": ["incident", "mcp-health", "break-glass"],
},
)
if not isinstance(incident_issue_result, dict) or "number" not in incident_issue_result:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
f"incident issue creation failed (#664 AC3): {_redact(str(incident_issue_result))}"
],
"blocker_kind": "incident_creation_failed",
"incident_issue": incident_issue_result,
})
except Exception as exc:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
f"incident issue creation failed with exception (#664 AC3): {_redact(str(exc))}"
],
"blocker_kind": "incident_creation_failed",
"incident_issue": {"error": _redact(str(exc))},
})
incident_number = incident_issue_result.get("number")
# B6/B11: reach the canonical non-dry-run executor/delegate.
# Authorization and apply_authorized do not mean execution occurred.
# Dry-run never reaches this path (returned earlier).
restart_exec_result = _run_break_glass_restart_executor({
"remote": remote,
"host": host,
"org": o,
"repo": r,
"restart_class": restart_class,
"correlation_id": correlation_id,
"reason": clean_reason,
"confirmation": clean_confirmation,
"profile_name": profile_name,
"active_role": active_role,
"incident_number": incident_number,
"disrupted_sessions_count": disrupted_count,
"request_break_glass": True,
"dry_run": False,
})
apply_supported = bool(restart_exec_result.get("apply_supported", False))
apply_authorized = bool(restart_exec_result.get("apply_authorized", False))
exec_success = bool(restart_exec_result.get("success", False))
# break_glass_executed is true only when the executor contract proves
# execution (or accepted host delegation) occurred.
restart_performed = bool(
restart_exec_result.get("restart_performed", False)
and restart_exec_result.get("break_glass_executed", False)
)
if not apply_supported:
term_audit = gitea_audit.build_event(
action="break_glass_mcp_restart_terminal",
result=gitea_audit.FAILED,
remote=remote,
server=(gitea_url(h, "").rstrip("/") if h else None),
repository=r,
profile_name=profile.get("profile_name", "unknown"),
audit_label=profile.get("audit_label", "unknown"),
authenticated_username=identity,
reason="apply_unsupported",
mcp_namespace=mcp_namespace,
task_role=active_role,
operation="break_glass_mcp_restart",
now=datetime.now(timezone.utc).isoformat(),
request_metadata={
"correlation_id": correlation_id,
"incident_number": incident_number,
"break_glass_executed": False,
"blocker_kind": "apply_unsupported",
},
)
gitea_audit.write_event(term_audit)
if incident_number:
try:
api_request(
"POST",
f"{repo_api_url(h, o, r)}/issues/{incident_number}/comments",
_auth(h),
{"body": _redact(f"**Terminal Status**: `apply_unsupported` - Restart coordinator does not support apply execution. No restart was performed. ({correlation_id})")},
)
except Exception:
pass
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"actor": identity,
"role": active_role,
"mcp_namespace": mcp_namespace,
"restart_class": restart_class,
"reasons": [
"break-glass apply is unsupported by restart coordinator (apply_supported=False) (#664)",
*(restart_exec_result.get("reasons") or []),
],
"blocker_kind": "apply_unsupported",
"restart_result": restart_exec_result,
"incident_issue": incident_issue_result,
"audit_record": pre_audit_event,
})
if not apply_authorized or not exec_success or not restart_performed:
term_audit = gitea_audit.build_event(
action="break_glass_mcp_restart_terminal",
result=gitea_audit.FAILED,
remote=remote,
server=(gitea_url(h, "").rstrip("/") if h else None),
repository=r,
profile_name=profile.get("profile_name", "unknown"),
audit_label=profile.get("audit_label", "unknown"),
authenticated_username=identity,
reason="restart_delegation_failed",
mcp_namespace=mcp_namespace,
task_role=active_role,
operation="break_glass_mcp_restart",
now=datetime.now(timezone.utc).isoformat(),
request_metadata={
"correlation_id": correlation_id,
"incident_number": incident_number,
"break_glass_executed": False,
"blocker_kind": "restart_delegation_failed",
},
)
gitea_audit.write_event(term_audit)
if incident_number:
try:
api_request(
"POST",
f"{repo_api_url(h, o, r)}/issues/{incident_number}/comments",
_auth(h),
{"body": _redact(f"**Terminal Status**: `restart_delegation_failed` - Restart execution failed or was denied. No restart was performed. ({correlation_id})")},
)
except Exception:
pass
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"actor": identity,
"role": active_role,
"mcp_namespace": mcp_namespace,
"restart_class": restart_class,
"reasons": [
"delegated restart execution failed or was denied by coordinator (#664)",
*(restart_exec_result.get("reasons") or []),
],
"blocker_kind": "restart_delegation_failed",
"restart_result": restart_exec_result,
"incident_issue": incident_issue_result,
"audit_record": pre_audit_event,
})
# Restart occurred! Perform mandatory post-restart reconciliation (B10)
recon_result = None
try:
recon_result = gitea_reconcile_after_restart(
remote=remote,
host=host,
org=org,
repo=repo,
)
except Exception as exc:
recon_result = {"success": False, "error": _redact(str(exc))}
recon_success = bool(recon_result and isinstance(recon_result, dict) and recon_result.get("success", False))
if not recon_success:
term_audit = gitea_audit.build_event(
action="break_glass_mcp_restart_terminal",
result=gitea_audit.FAILED,
remote=remote,
server=(gitea_url(h, "").rstrip("/") if h else None),
repository=r,
profile_name=profile.get("profile_name", "unknown"),
audit_label=profile.get("audit_label", "unknown"),
authenticated_username=identity,
reason="reconciliation_failed",
mcp_namespace=mcp_namespace,
task_role=active_role,
operation="break_glass_mcp_restart",
now=datetime.now(timezone.utc).isoformat(),
request_metadata={
"correlation_id": correlation_id,
"incident_number": incident_number,
"break_glass_executed": True,
"reconciliation_success": False,
"blocker_kind": "reconciliation_failed",
},
)
gitea_audit.write_event(term_audit)
if incident_number:
try:
api_request(
"POST",
f"{repo_api_url(h, o, r)}/issues/{incident_number}/comments",
_auth(h),
{"body": _redact(f"**Terminal Status**: `reconciliation_failed` - Restart was executed but post-restart reconciliation failed. ({correlation_id})")},
)
except Exception:
pass
return gitea_audit.redact({
"success": False,
"performed": True,
"break_glass_executed": True,
"actor": identity,
"role": active_role,
"mcp_namespace": mcp_namespace,
"restart_class": restart_class,
"reasons": [
"break-glass restart executed but post-restart reconciliation failed (#664/#662)"
],
"blocker_kind": "reconciliation_failed",
"restart_result": restart_exec_result,
"reconciliation_result": recon_result,
"incident_issue": incident_issue_result,
"audit_record": pre_audit_event,
})
# Terminal audit append for successful execution + reconciliation
term_audit = gitea_audit.build_event(
action="break_glass_mcp_restart_terminal",
result=gitea_audit.SUCCEEDED,
remote=remote,
server=(gitea_url(h, "").rstrip("/") if h else None),
repository=r,
profile_name=profile.get("profile_name", "unknown"),
audit_label=profile.get("audit_label", "unknown"),
authenticated_username=identity,
reason=clean_reason,
mcp_namespace=mcp_namespace,
task_role=active_role,
operation="break_glass_mcp_restart",
now=datetime.now(timezone.utc).isoformat(),
request_metadata={
"correlation_id": correlation_id,
"incident_number": incident_number,
"break_glass_executed": True,
"reconciliation_success": True,
},
)
term_write_success = gitea_audit.write_event(term_audit)
if incident_number:
try:
api_request(
"POST",
f"{repo_api_url(h, o, r)}/issues/{incident_number}/comments",
_auth(h),
{"body": _redact(f"**Terminal Status**: `succeeded` - Break-glass restart executed and reconciled successfully ({correlation_id}).")},
)
except Exception:
pass
if not term_write_success:
return gitea_audit.redact({
"success": False,
"performed": True,
"break_glass_executed": True,
"actor": identity,
"role": active_role,
"mcp_namespace": mcp_namespace,
"restart_class": restart_class,
"reasons": [
"break-glass restart executed and reconciled but terminal audit recording failed (#664)"
],
"blocker_kind": "terminal_audit_failed",
"restart_result": restart_exec_result,
"reconciliation_result": recon_result,
"incident_issue": incident_issue_result,
"audit_record": pre_audit_event,
})
return gitea_audit.redact({
"success": True,
"performed": True,
"dry_run": False,
"break_glass_executed": True,
"would_execute": True,
"correlation_id": correlation_id,
"actor": identity,
"role": active_role,
"mcp_namespace": mcp_namespace,
"restart_class": restart_class,
"reason": clean_reason,
"confirmation": clean_confirmation,
"disrupted_sessions_count": disrupted_count,
"disrupted_sessions": disrupted_sessions,
"audit_record": term_audit,
"saved_audit": term_audit,
"incident_issue": incident_issue_result,
"reconciliation_result": recon_result,
"reconciliation_required": True,
"reconciliation_tool": "gitea_reconcile_after_restart",
"follow_up_issue_required": True,
"cross_references": ["#652", "#653", "#655", "#630", "#658", "#662", "#664"],
"reasons": [
"break-glass restart executed with incident creation and mandatory reconciliation"
],
})
# --- #662 post-restart reconciliation --------------------------------------- # --- #662 post-restart reconciliation ---------------------------------------
_POST_RESTART_LAST_PROOF: dict | None = None _POST_RESTART_LAST_PROOF: dict | None = None
+8 -3
View File
@@ -37,12 +37,17 @@ def normalize_role_kind(
*, *,
profile_name: str | None = None, profile_name: str | None = None,
) -> str: ) -> str:
"""Map profile/task role to a workspace namespace key.""" """Map profile/task role to a workspace namespace key.
Exact profile-name matches only for controller routing (#840 / #664 B1):
substring lookalikes such as ``fake-controller`` must not become
controller.
"""
role = (role_kind or "author").strip().lower() role = (role_kind or "author").strip().lower()
profile = (profile_name or "").strip().lower() profile = (profile_name or "").strip().lower()
if role == "reviewer" and "merger" in profile: if role == "reviewer" and profile in ("prgs-merger", "mdcps-merger", "merger"):
return "merger" return "merger"
if "controller" in profile or role == "controller": if role == "controller" or profile in ("prgs-controller", "controller"):
return "controller" return "controller"
if role in ROLE_WORKTREE_ENVS: if role in ROLE_WORKTREE_ENVS:
return role return role
+9
View File
@@ -576,6 +576,15 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "runtime.record_analytics_usage", "permission": "runtime.record_analytics_usage",
"role": "author", "role": "author",
}, },
# #664: emergency break-glass MCP restart workflow (privileged controller role).
"break_glass_restart": {
"permission": "runtime.break_glass_restart",
"role": "controller",
},
"gitea_break_glass_restart": {
"permission": "runtime.break_glass_restart",
"role": "controller",
},
} }
+982
View File
@@ -0,0 +1,982 @@
"""Tests for emergency break-glass MCP restart workflow (#664).
Regression suite for review #641 remediation: B13, B1, B14, B6/B11, B8, and
preservation of previously accepted B2/B3/B5/B7/B9/B10 corrections.
"""
from __future__ import annotations
import os
import unittest
from unittest.mock import MagicMock, patch
import gitea_audit
import gitea_config
import gitea_mcp_server
def _controller_profile(**extra) -> dict:
base = {
"profile_name": "prgs-controller",
"execution_profile": "prgs-controller",
"role": "reconciler", # declared role must not be redefined by capability
"allowed_operations": [
"gitea.read",
"gitea.issue.create",
"gitea.branch.delete",
"gitea.pr.close",
"gitea.pr.comment",
"gitea.issue.comment",
"runtime.break_glass_restart",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.create",
"gitea.branch.push",
],
}
base.update(extra)
return base
def _gate_open_patches():
"""Keep master-parity / runtime-mode blocks out of unit tests."""
return (
patch.object(gitea_mcp_server, "_master_parity_block", return_value=[]),
patch.object(gitea_mcp_server, "_runtime_mode_block", return_value=[]),
patch.object(gitea_mcp_server, "_try_auto_switch_for_operation", return_value=False),
)
class TestBreakGlassRestart(unittest.TestCase):
"""Test suite for gitea_break_glass_restart tool and guardrails (#664)."""
def setUp(self) -> None:
self.env_patcher = patch.dict(os.environ, {}, clear=False)
self.env_patcher.start()
os.environ.pop("GITEA_BREAKGLASS_RESTART_AUTHORIZATION", None)
os.environ.pop("GITEA_SANCTIONED_RESTART_HOOK", None)
os.environ.pop("GITEA_AUDIT_LOG", None)
# Reset injectable executor between tests.
gitea_mcp_server._break_glass_restart_executor = None
def tearDown(self) -> None:
gitea_mcp_server._break_glass_restart_executor = None
self.env_patcher.stop()
# ── B13: operation registration / real gate ───────────────────────────
def test_normalize_operation_accepts_canonical_break_glass_op(self) -> None:
"""B13: production normalizer accepts exact runtime.break_glass_restart."""
self.assertEqual(
gitea_config.normalize_operation(
"runtime.break_glass_restart", service="runtime"
),
"runtime.break_glass_restart",
)
ok, reason = gitea_config.check_operation(
"runtime.break_glass_restart",
["gitea.read", "runtime.break_glass_restart"],
)
self.assertTrue(ok, reason)
self.assertEqual(reason, "allowed")
def test_normalize_unknown_and_misspelled_ops_fail_closed(self) -> None:
"""B13: unknown / misspelled operations fail closed (no gitea.read fallback)."""
# Well-formed but misspelled runtime op normalizes, then is not allowed.
ok, reason = gitea_config.check_operation(
"runtime.break_glass_restar", # misspelled
["gitea.read", "runtime.break_glass_restart"],
)
self.assertFalse(ok)
self.assertEqual(reason, "not-allowed")
ok2, reason2 = gitea_config.check_operation(
"runtime.break_glass_restart",
["gitea.read"], # capability not granted
)
self.assertFalse(ok2)
self.assertEqual(reason2, "not-allowed")
ok3, reason3 = gitea_config.check_operation(
"frobnicate",
["gitea.read", "runtime.break_glass_restart"],
)
self.assertFalse(ok3)
self.assertEqual(reason3, "invalid-operation")
# gitea.read grant alone never authorizes break-glass.
ok4, reason4 = gitea_config.check_operation(
"runtime.break_glass_restart",
["gitea.read"],
)
self.assertFalse(ok4)
self.assertNotEqual(reason4, "allowed")
def test_real_profile_operation_gate_without_stubbing(self) -> None:
"""B13: exercise real _profile_operation_gate (not stubbed)."""
allowed = _controller_profile()
denied = _controller_profile(
allowed_operations=["gitea.read", "gitea.issue.create"]
)
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=allowed):
self.assertEqual(
gitea_mcp_server._profile_operation_gate(
"runtime.break_glass_restart"
),
[],
)
with patch.object(gitea_mcp_server, "get_profile", return_value=denied):
reasons = gitea_mcp_server._profile_operation_gate(
"runtime.break_glass_restart"
)
self.assertTrue(reasons)
self.assertTrue(
any("runtime.break_glass_restart" in r or "not allowed" in r
for r in reasons)
)
def test_entry_point_uses_same_operation_as_gate(self) -> None:
"""B13: entry point enforces runtime.break_glass_restart, not gitea.read."""
# Profile has gitea.read but not the break-glass capability.
prof = _controller_profile(
allowed_operations=["gitea.read", "gitea.issue.create"]
)
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart required due to deadlock in worker pool",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "permission_denied")
self.assertNotIn("gitea.read", " ".join(res.get("reasons") or []))
# ── B1 / B14: exact profile auth, no substring, role preservation ─────
def test_trusted_prgs_controller_authorized(self) -> None:
"""B1: exact trusted prgs-controller with capability is authorized."""
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": []},
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart required due to deadlock in worker pool",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=True,
remote="prgs",
)
self.assertTrue(res["success"], res)
self.assertFalse(res["break_glass_executed"])
def test_fabricated_controller_like_profile_names_denied(self) -> None:
"""B1: lookalike profile names never become authorized."""
p_parity, p_runtime, p_switch = _gate_open_patches()
for name in (
"fake-controller",
"controller-copy",
"not-controller",
"xcontrollerx",
"CONTROLLER",
"prgs-controller-copy",
):
prof = _controller_profile(profile_name=name, execution_profile=name)
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart required due to deadlock in worker pool",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=True,
remote="prgs",
)
self.assertFalse(res["success"], name)
self.assertEqual(res["blocker_kind"], "role_authorization", name)
self.assertFalse(res["break_glass_executed"])
def test_ordinary_and_non_controller_reconciler_denied(self) -> None:
"""B1: ordinary roles and non-controller reconcilers are denied."""
p_parity, p_runtime, p_switch = _gate_open_patches()
denied = [
{"profile_name": "prgs-author", "role": "author",
"allowed_operations": ["gitea.read", "runtime.break_glass_restart"],
"forbidden_operations": []},
{"profile_name": "prgs-reviewer", "role": "reviewer",
"allowed_operations": ["gitea.read", "runtime.break_glass_restart"],
"forbidden_operations": []},
{"profile_name": "prgs-merger", "role": "merger",
"allowed_operations": ["gitea.read", "runtime.break_glass_restart"],
"forbidden_operations": []},
{"profile_name": "prgs-reconciler", "role": "reconciler",
"allowed_operations": [
"gitea.read", "gitea.branch.delete", "runtime.break_glass_restart"
],
"forbidden_operations": []},
{"profile_name": "prgs-controller", "role": "reconciler",
"allowed_operations": ["gitea.read"],
"forbidden_operations": []}, # no capability
]
for prof in denied:
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart required due to deadlock in worker pool",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=True,
remote="prgs",
)
self.assertFalse(res["success"], prof)
self.assertFalse(res["break_glass_executed"], prof)
self.assertIn(
res["blocker_kind"],
("role_authorization", "permission_denied"),
prof,
)
def test_env_var_cannot_grant_authorization_or_bypass_denial(self) -> None:
"""B2 preserved: env var cannot grant break-glass authorization."""
os.environ["GITEA_BREAKGLASS_RESTART_AUTHORIZATION"] = "secret-bypass-token"
prof = {
"profile_name": "prgs-author",
"role": "author",
"allowed_operations": [
"gitea.read", "gitea.issue.create", "runtime.break_glass_restart"
],
"forbidden_operations": [],
}
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart attempting env var bypass",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "role_authorization")
def test_profile_role_kind_no_substring_authority(self) -> None:
"""B1/B14: substring lookalikes do not become controller."""
for name in (
"fake-controller",
"controller-copy",
"not-controller",
"xcontrollerx",
"myCONTROLLER",
):
prof = {
"profile_name": name,
"role": "author",
"allowed_operations": ["gitea.read"],
}
self.assertEqual(
gitea_mcp_server._profile_role_kind(prof),
"author",
name,
)
# Role substrings must not promote.
for role in ("not-controller", "control-plane", "xcontrollerx"):
prof = {"profile_name": "other", "role": role, "allowed_operations": ["gitea.read"]}
self.assertEqual(
gitea_mcp_server._profile_role_kind(prof),
role,
role,
)
def test_prgs_controller_retains_declared_reconciler_role(self) -> None:
"""B14: break-glass capability does not redefine global role."""
prof = _controller_profile(role="reconciler")
self.assertEqual(gitea_mcp_server._profile_role_kind(prof), "reconciler")
# Declared controller still wins when declared.
prof2 = _controller_profile(role="controller")
self.assertEqual(gitea_mcp_server._profile_role_kind(prof2), "controller")
def test_cleanup_merged_pr_branch_role_resolution_unchanged(self) -> None:
"""B14: prgs-controller with reconciler role still resolves for cleanup."""
# When declared reconciler, cleanup gate's role check should see reconciler.
prof = _controller_profile(role="reconciler")
self.assertEqual(gitea_mcp_server._profile_role_kind(prof), "reconciler")
# Fabricated controller-like names with reconciler ops do not become controller.
fake = {
"profile_name": "fake-controller",
"role": "reconciler",
"allowed_operations": [
"gitea.read", "gitea.branch.delete", "gitea.pr.close"
],
}
self.assertEqual(gitea_mcp_server._profile_role_kind(fake), "reconciler")
def test_narrow_break_glass_capability_does_not_redefine_role(self) -> None:
"""B14: granting runtime.break_glass_restart does not invent controller role."""
prof = {
"profile_name": "prgs-reconciler",
"role": "reconciler",
"allowed_operations": [
"gitea.read",
"gitea.branch.delete",
"runtime.break_glass_restart",
],
}
self.assertEqual(gitea_mcp_server._profile_role_kind(prof), "reconciler")
# ── B2-style input validation (preserved) ─────────────────────────────
def test_reason_validation(self) -> None:
"""AC2: Reason is required and must be at least 10 characters long."""
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
for invalid_reason in ["", " ", "too short", "123456789"]:
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
res = gitea_mcp_server.gitea_break_glass_restart(
reason=invalid_reason,
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertEqual(res["blocker_kind"], "missing_required_fields")
def test_confirmation_validation(self) -> None:
"""AC2: Confirmation phrase must match exact required string."""
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart needed due to stuck daemon processes",
confirmation="wrong_confirmation_phrase",
impact_ack=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertEqual(res["blocker_kind"], "confirmation_mismatch")
def test_impact_ack_validation(self) -> None:
"""AC2: impact_ack=True is mandatory."""
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart needed due to stuck daemon processes",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=False,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertEqual(res["blocker_kind"], "impact_ack_required")
def test_incident_permission_gate(self) -> None:
"""B3 preserved: Gate incident creation on gitea.issue.create permission."""
prof = _controller_profile(
allowed_operations=["gitea.read", "runtime.break_glass_restart"]
)
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart needed due to hung worker process cohort",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
create_incident_issue=True,
dry_run=False,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertEqual(res["blocker_kind"], "permission_denied")
# ── B8: redaction ─────────────────────────────────────────────────────
def test_redaction_key_value_and_connection_strings(self) -> None:
"""B8: key/value, bearer, connection-string, and embedded secrets."""
cases = [
("password=hunter2supersecret", ["hunter2supersecret"], "password"),
("api_key: sk-live-abcdef1234567890ab", ["sk-live-abcdef1234567890ab"], "api_key"),
("Server=db;Password=s3cretValue;Uid=sa", ["s3cretValue"], "password"),
(
"Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc.def",
["eyJhbGciOiJIUzI1NiJ9.abc.def", "Bearer eyJ"],
"authorization",
),
(
"token ghp_1234567890abcdef12345678 embedded",
["ghp_1234567890abcdef12345678"],
"token",
),
("nested note password=letmein12345 end", ["letmein12345"], "password"),
]
for raw, secrets, key in cases:
out = gitea_audit._redact_str(raw)
self.assertIn("[REDACTED]", out, raw)
for secret in secrets:
self.assertNotIn(secret, out, raw)
self.assertIn(key, out.lower(), raw)
nested = gitea_audit.redact({
"reason": "password=supersecret99",
"items": [{"api_key": "abc123xyz"}, "token ghp_abcdefghijklmnop1234"],
"error": RuntimeError("pwd=nestedSecret99"),
})
# Exception objects pass through redact as non-str/non-container; ensure
# string forms are covered via str conversion in _redact_str usage.
self.assertEqual(nested["items"][0]["api_key"], gitea_audit.REDACTED)
self.assertNotIn("supersecret99", nested["reason"])
self.assertNotIn("ghp_abcdefghijklmnop1234", nested["items"][1])
def test_redaction_preserves_benign_sec_prefix_text(self) -> None:
"""B8: ordinary text beginning with sec- must not be erased."""
benign = (
"Emergency restart in sec-primary-region for sector-planning "
"and secondary-health checks"
)
out = gitea_audit._redact_str(benign)
self.assertIn("sec-primary-region", out)
self.assertIn("sector-planning", out)
self.assertIn("secondary-health", out)
self.assertNotIn("[REDACTED]", out)
def test_redaction_across_break_glass_surfaces(self) -> None:
"""B8: operator reason is redacted on result, incident, and audit surfaces."""
prof = _controller_profile()
raw_reason = (
"Emergency restart: password=supersecret99 api_key: "
"sk-live-abcdef1234567890ab and url https://user:[email protected]/api"
)
mock_api_request = MagicMock(return_value={"number": 101, "title": "[INCIDENT]"})
fake_exec = {
"success": True,
"apply_supported": True,
"apply_authorized": True,
"restart_performed": True,
"break_glass_executed": True,
}
gitea_mcp_server._break_glass_restart_executor = lambda req: fake_exec
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "_auth", return_value={"Authorization": "token test"}
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "api_request", mock_api_request
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": []},
), patch.object(
gitea_mcp_server, "gitea_reconcile_after_restart",
return_value={"success": True},
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=True
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason=raw_reason,
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertTrue(res["success"], res)
self.assertNotIn("supersecret99", res["reason"])
self.assertNotIn("sk-live-abcdef1234567890ab", res["reason"])
self.assertNotIn("user:[email protected]", res["reason"])
posted_body = mock_api_request.call_args[0][3]["body"]
self.assertNotIn("supersecret99", posted_body)
self.assertNotIn("sk-live-abcdef1234567890ab", posted_body)
# ── B6/B11: reachable executor / truthful flags ───────────────────────
def test_dry_run_never_executes_and_reports_false(self) -> None:
"""B7/B6: dry-run never executes; break_glass_executed always false."""
prof = _controller_profile()
called = {"n": 0}
def _should_not_run(_req):
called["n"] += 1
return {"restart_performed": True, "break_glass_executed": True}
gitea_mcp_server._break_glass_restart_executor = _should_not_run
mock_audit = MagicMock()
mock_api = MagicMock()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": [{"session_id": "s1"}]},
), patch.object(
gitea_audit, "write_event", mock_audit
), patch.object(
gitea_mcp_server, "api_request", mock_api
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart preview in dry-run mode",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=True,
remote="prgs",
)
self.assertTrue(res["success"])
self.assertTrue(res["dry_run"])
self.assertFalse(res["break_glass_executed"])
self.assertTrue(res["would_execute"])
self.assertEqual(called["n"], 0)
mock_audit.assert_not_called()
mock_api.assert_not_called()
def test_unsupported_apply_truthful(self) -> None:
"""B6/B11: unsupported apply returns blocked result, execution false."""
prof = _controller_profile()
gitea_mcp_server._break_glass_restart_executor = lambda req: {
"success": False,
"apply_supported": False,
"apply_authorized": False,
"restart_performed": False,
"break_glass_executed": False,
"reasons": ["no hook"],
}
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "_auth", return_value={"Authorization": "token test"}
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": []},
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=True
), patch.object(
gitea_mcp_server, "api_request",
return_value={"number": 555, "title": "[INCIDENT]"},
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Privileged restart request with unsupported apply",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["performed"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "apply_unsupported")
def test_delegation_success_rejection_and_failure(self) -> None:
"""B6/B11: distinct terminal states for success / rejection / failure."""
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
def _run(exec_result, recon=None):
gitea_mcp_server._break_glass_restart_executor = lambda req: exec_result
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "_auth", return_value={"Authorization": "token test"}
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": []},
), patch.object(
gitea_mcp_server, "gitea_reconcile_after_restart",
return_value=recon or {"success": True},
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=True
), patch.object(
gitea_mcp_server, "api_request",
return_value={"number": 555, "title": "[INCIDENT]"},
):
return gitea_mcp_server.gitea_break_glass_restart(
reason="Privileged break-glass restart delegation path",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
ok = _run({
"success": True,
"apply_supported": True,
"apply_authorized": True,
"restart_performed": True,
"break_glass_executed": True,
})
self.assertTrue(ok["success"], ok)
self.assertTrue(ok["break_glass_executed"])
self.assertTrue(ok["performed"])
rejected = _run({
"success": False,
"apply_supported": True,
"apply_authorized": False,
"restart_performed": False,
"break_glass_executed": False,
"reasons": ["class denied"],
})
self.assertFalse(rejected["success"])
self.assertFalse(rejected["break_glass_executed"])
self.assertEqual(rejected["blocker_kind"], "restart_delegation_failed")
failed = _run({
"success": False,
"apply_supported": True,
"apply_authorized": True,
"restart_performed": False,
"break_glass_executed": False,
"reasons": ["executor error"],
})
self.assertFalse(failed["success"])
self.assertFalse(failed["break_glass_executed"])
self.assertEqual(failed["blocker_kind"], "restart_delegation_failed")
def test_reconciliation_success_and_failure_truthful_flags(self) -> None:
"""B10 preserved: recon failure keeps break_glass_executed=true."""
prof = _controller_profile()
gitea_mcp_server._break_glass_restart_executor = lambda req: {
"success": True,
"apply_supported": True,
"apply_authorized": True,
"restart_performed": True,
"break_glass_executed": True,
}
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "_auth", return_value={"Authorization": "token test"}
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": []},
), patch.object(
gitea_mcp_server, "gitea_reconcile_after_restart",
return_value={"success": False, "error": "lease cleanup failed"},
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=True
), patch.object(
gitea_mcp_server, "api_request",
return_value={"number": 555, "title": "[INCIDENT]"},
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Privileged restart request with failing reconciliation",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertTrue(res["performed"])
self.assertTrue(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "reconciliation_failed")
def test_authorization_is_not_execution(self) -> None:
"""B6: apply_authorized alone never sets break_glass_executed."""
# Default executor without hook → unsupported, not executed.
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "_auth", return_value={"Authorization": "token test"}
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": []},
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=True
), patch.object(
gitea_mcp_server, "api_request",
return_value={"number": 555, "title": "[INCIDENT]"},
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Privileged restart without host hook configured",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "apply_unsupported")
# ── preserved fail-closed pre-exec (B5/B9) ────────────────────────────
def test_audit_failure_before_execution_fails_closed(self) -> None:
"""B9 preserved: Audit recording failure stops execution fail-closed."""
prof = _controller_profile()
called = {"n": 0}
gitea_mcp_server._break_glass_restart_executor = lambda req: called.__setitem__("n", called["n"] + 1) or {}
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": []},
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=False
), patch.object(
gitea_mcp_server, "api_request", MagicMock()
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart with failing audit sink",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "audit_recording_failed")
self.assertEqual(called["n"], 0)
def test_incident_creation_failure_before_execution_fails_closed(self) -> None:
"""B5 preserved: Incident creation failure stops execution fail-closed."""
prof = _controller_profile()
called = {"n": 0}
gitea_mcp_server._break_glass_restart_executor = lambda req: called.__setitem__("n", called["n"] + 1) or {}
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "_auth", return_value={"Authorization": "token test"}
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": []},
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=True
), patch.object(
gitea_mcp_server, "api_request",
side_effect=RuntimeError("Gitea 500 API Error"),
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart with failing incident POST",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "incident_creation_failed")
self.assertEqual(called["n"], 0)
def test_incident_opt_out_on_real_execution_fails_closed(self) -> None:
"""B5 preserved: create_incident_issue=False fails closed on real execution."""
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": []},
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart trying to skip incident creation",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=False,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "incident_creation_required")
def test_audit_disabled_fails_closed(self) -> None:
"""B9 preserved: Disabling audit recording blocks execution fail-closed."""
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_audit, "audit_enabled", return_value=False
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": []},
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart with disabled audit logging",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "audit_recording_failed")
def test_capability_map_registration(self) -> None:
"""B12/B13: capability map registers exact runtime.break_glass_restart."""
from task_capability_map import TASK_CAPABILITY_MAP
entry = TASK_CAPABILITY_MAP.get("gitea_break_glass_restart")
self.assertIsNotNone(entry)
self.assertEqual(entry["permission"], "runtime.break_glass_restart")
self.assertEqual(entry["role"], "controller")
entry2 = TASK_CAPABILITY_MAP.get("break_glass_restart")
self.assertEqual(entry2["permission"], "runtime.break_glass_restart")
# ── B8 / B6 / B15 remediation tests ─────────────────────────────────────
def test_b8_redaction_gitea_token_and_uri_credentials(self) -> None:
"""B8: GITEA_TOKEN= and URI userinfo credentials redacted without erasing neighbours."""
# GITEA_TOKEN= with underscore key
out1 = gitea_audit._redact_str("failed with GITEA_TOKEN=synthetic_tok_123456789")
self.assertIn("GITEA_TOKEN=[REDACTED]", out1)
self.assertNotIn("synthetic_tok_123456789", out1)
# Connection string with URI userinfo
out2 = gitea_audit._redact_str("conn postgres://user:[email protected]:5432/app")
self.assertIn("postgres://[REDACTED_USER]:[REDACTED_PASS]@db.internal:5432/app", out2)
self.assertNotIn("s3cr3tpw", out2)
# Value boundary preserving adjacent audit evidence (correlation_id, incident_number)
raw_audit = "password=secret123;correlation_id=bg-7f2a1c;incident_number=4242"
out3 = gitea_audit._redact_str(raw_audit)
self.assertIn("password=[REDACTED]", out3)
self.assertIn("correlation_id=bg-7f2a1c", out3)
self.assertIn("incident_number=4242", out3)
self.assertNotIn("secret123", out3)
# Query param boundary in URL preserving adjacent parameters
raw_url = "token=abc-123&pr=908&issue=664&head=c67f39b4"
out4 = gitea_audit._redact_str(raw_url)
self.assertIn("token=[REDACTED]", out4)
self.assertIn("pr=908", out4)
self.assertIn("issue=664", out4)
self.assertIn("head=c67f39b4", out4)
def test_b6_default_executor_environment_text_cannot_imply_execution(self) -> None:
"""B6/B11: GITEA_SANCTIONED_RESTART_HOOK string alone returns break_glass_executed=False."""
os.environ["GITEA_SANCTIONED_RESTART_HOOK"] = "this-string-is-never-invoked"
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "_auth", return_value={"Authorization": "token test"}
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": []},
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=True
), patch.object(
gitea_mcp_server, "api_request",
return_value={"number": 555, "title": "[INCIDENT]"},
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Privileged restart request with non-empty hook env var",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["performed"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "restart_delegation_failed")
def test_b15_production_prgs_controller_grant_set_and_gates(self) -> None:
"""B15: Genuine prgs-controller carrying runtime.break_glass_restart and gitea.issue.create passes real gates."""
# Full production-shaped prgs-controller profile
prod_profile = {
"profile_name": "prgs-controller",
"execution_profile": "prgs-controller",
"role": "reconciler",
"allowed_operations": [
"gitea.read",
"gitea.pr.close",
"gitea.pr.comment",
"gitea.issue.comment",
"gitea.issue.create",
"runtime.break_glass_restart",
"gitea.branch.delete",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.create",
"gitea.branch.push",
],
}
# 1. Real _profile_operation_gate checks
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prod_profile):
# Both required operations pass the real operation gate (no stubs)
self.assertEqual(
gitea_mcp_server._profile_operation_gate("runtime.break_glass_restart"),
[],
)
self.assertEqual(
gitea_mcp_server._profile_operation_gate("gitea.issue.create"),
[],
)
# 2. Missing gitea.issue.create fails incident creation gate
no_issue_create = dict(prod_profile)
no_issue_create["allowed_operations"] = [
"gitea.read", "gitea.pr.close", "runtime.break_glass_restart"
]
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=no_issue_create):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Restart testing missing gitea.issue.create permission",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertEqual(res["blocker_kind"], "permission_denied")
self.assertIn("gitea.issue.create", " ".join(res.get("reasons") or []))
if __name__ == "__main__":
unittest.main()
+323
View File
@@ -0,0 +1,323 @@
"""Validation tooling for the remote-MCP threat model (#956).
#956 requires that "every boundary claim [is] traceable to a file and line
anchor that resolves at the reviewed commit". A prose document cannot enforce
that about itself, and #930 demonstrated the failure mode: its inventory cited
``gitea_mcp_server.py`` anchors generated at ``7bf4f125`` which no longer point
at the described code at ``aad5c8b4``. Nothing failed, because nothing checked.
These tests are that check. They enforce, in both directions:
* every ``file.py:NNN`` anchor cited in the prose is declared in the fixture;
* every declared anchor resolves — the file exists, the line exists, and the
source line actually contains the substring the fixture claims for it;
* the document's structural obligations (assets, adversaries, boundaries,
credential rows, the co-residency ruling, and the child mapping) are present
and internally consistent.
A refactor that shifts a line number therefore breaks the suite instead of
silently rotting the security documentation.
"""
import json
import os
import re
import unittest
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DOC_PATH = os.path.join(REPO_ROOT, "docs", "remote-mcp", "threat-model.md")
FIXTURE_PATH = os.path.join(
REPO_ROOT, "docs", "remote-mcp", "threat-model-anchors.json"
)
# ``module.py:123`` as it appears inside markdown inline code spans.
ANCHOR_RE = re.compile(r"`([A-Za-z0-9_./-]+\.py):(\d+)`")
# The epic children this document must map to a boundary (#929 children 2-10).
REQUIRED_CHILDREN = [931, 932, 933, 934, 935, 936, 937, 938, 939]
# The adversaries #956 names explicitly.
REQUIRED_ADVERSARIES = [
"compromised LLM client",
"prompt injection",
"malicious tool arguments",
"network attacker",
"curious operator",
]
def _read(path):
with open(path, "r", encoding="utf-8") as fh:
return fh.read()
def _heading_re(title):
"""Match a level-2 heading by title, with or without section numbering.
The document numbers its sections ('## 6. Decomposition ruling'), so an
exact-substring assertion would break on renumbering without the document
having actually lost anything.
"""
return re.compile(
r"^##\s+(?:\d+\.\s+)?" + re.escape(title), re.MULTILINE
)
def _section_body(doc, title):
"""Return the text of section *title*, bounded by the next level-2 heading.
Bounding matters: an unbounded slice runs to end-of-document, so the
walkthrough tables in a later section leak into the child-to-boundary
mapping and satisfy its coverage check with rows that assign no owner.
"""
match = _heading_re(title).search(doc)
if match is None:
return None
rest = doc[match.end():]
nxt = re.search(r"^##\s", rest, re.MULTILINE)
return rest[: nxt.start()] if nxt else rest
def _source_line(rel_path, lineno):
"""Return the 1-based *lineno* of *rel_path*, or None if out of range."""
abs_path = os.path.join(REPO_ROOT, rel_path)
if not os.path.exists(abs_path):
return None
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
for idx, line in enumerate(fh, start=1):
if idx == lineno:
return line
return None
class ThreatModelFixtureTests(unittest.TestCase):
"""The fixture itself must be well-formed before it can prove anything."""
def setUp(self):
self.fixture = json.loads(_read(FIXTURE_PATH))
def test_fixture_declares_a_generation_commit(self):
sha = self.fixture.get("generated_against_commit") or ""
self.assertRegex(
sha,
r"^[0-9a-f]{40}$",
"the fixture must record the full commit its anchors were taken at",
)
def test_fixture_anchors_are_unique_and_well_formed(self):
seen = set()
for entry in self.fixture["anchors"]:
anchor = entry["anchor"]
self.assertNotIn(anchor, seen, f"duplicate anchor entry: {anchor}")
seen.add(anchor)
self.assertRegex(anchor, r"^[A-Za-z0-9_./-]+\.py:[1-9]\d*$", anchor)
self.assertTrue(
(entry.get("expect") or "").strip(),
f"anchor {anchor} declares no 'expect' substring, so it proves nothing",
)
class ThreatModelAnchorResolutionTests(unittest.TestCase):
"""#956 required positive test: every anchor resolves at the reviewed commit."""
def setUp(self):
self.fixture = json.loads(_read(FIXTURE_PATH))
self.doc = _read(DOC_PATH)
def test_every_declared_anchor_resolves_to_the_claimed_source_line(self):
failures = []
for entry in self.fixture["anchors"]:
rel_path, _, raw_lineno = entry["anchor"].partition(":")
lineno = int(raw_lineno)
line = _source_line(rel_path, lineno)
if line is None:
failures.append(f"{entry['anchor']}: file or line does not exist")
continue
if entry["expect"] not in line:
failures.append(
f"{entry['anchor']}: expected {entry['expect']!r}, "
f"found {line.strip()!r}"
)
self.assertEqual(
[], failures, "unresolved threat-model anchors:\n" + "\n".join(failures)
)
def test_every_anchor_cited_in_the_document_is_declared_in_the_fixture(self):
declared = {e["anchor"] for e in self.fixture["anchors"]}
cited = {f"{m.group(1)}:{m.group(2)}" for m in ANCHOR_RE.finditer(self.doc)}
undeclared = sorted(cited - declared)
self.assertEqual(
[],
undeclared,
"document cites anchors that no test verifies: " + ", ".join(undeclared),
)
def test_the_document_actually_cites_anchors(self):
cited = {f"{m.group(1)}:{m.group(2)}" for m in ANCHOR_RE.finditer(self.doc)}
self.assertGreaterEqual(
len(cited),
30,
"a boundary document with almost no anchors is not traceable",
)
def test_unresolvable_anchor_is_detected(self):
"""Negative control: the checker must fail on a deliberately bad anchor.
Without this, a checker that silently passed everything would look
identical to a correct one.
"""
self.assertIsNone(_source_line("gitea_config.py", 10**9))
self.assertIsNone(_source_line("no_such_module_for_956.py", 1))
real = _source_line("gitea_config.py", 54)
self.assertIsNotNone(real)
self.assertNotIn("this substring is not on that line", real)
class ThreatModelStructureTests(unittest.TestCase):
"""The document must contain what #956's acceptance criteria demand."""
def setUp(self):
self.doc = _read(DOC_PATH)
def test_records_the_commit_it_was_generated_against(self):
fixture = json.loads(_read(FIXTURE_PATH))
self.assertIn(
fixture["generated_against_commit"],
self.doc,
"the document must state the commit its anchors resolve at",
)
def test_names_every_required_adversary(self):
low = self.doc.lower()
for adversary in REQUIRED_ADVERSARIES:
self.assertIn(adversary.lower(), low, f"adversary not covered: {adversary}")
def test_maps_every_epic_child_from_two_through_ten(self):
for number in REQUIRED_CHILDREN:
self.assertIn(
f"#{number}",
self.doc,
f"epic child #{number} is not mapped to a boundary",
)
def test_credential_rows_declare_holder_boundary_and_blast_radius(self):
for column in ("Holder", "Boundary", "Blast radius"):
self.assertIn(
column,
self.doc,
f"the credential inventory must state each credential's {column.lower()}",
)
def test_states_an_explicit_co_residency_ruling(self):
"""AC3/AC5: an explicit ruling, not an implication."""
self.assertIsNotNone(
_heading_re("Decomposition ruling").search(self.doc),
"the document must contain an explicit decomposition-ruling section",
)
for service in ("Jenkins", "GlitchTip", "Sentry", "database"):
self.assertIn(service, self.doc, f"ruling does not address {service}")
self.assertRegex(
self.doc,
r"D1\b.*must not",
"the ruling must state the prohibition, not merely discuss it",
)
def test_contains_the_compromised_client_walkthrough(self):
"""#956 required negative/adversarial test."""
self.assertIsNotNone(
_heading_re("Adversarial walkthrough").search(self.doc),
"the required compromised-client walkthrough is missing",
)
self.assertIn("Before the migration", self.doc)
self.assertIn("After the migration", self.doc)
def test_every_boundary_states_what_it_protects_and_what_crossing_requires(self):
boundary_ids = set(re.findall(r"\bB(\d+)\b", self.doc))
self.assertGreaterEqual(
len(boundary_ids), 5, "too few trust boundaries to be a decomposition"
)
for column in (
"Protects",
"Crossing requires today",
"Crossing must require remotely",
):
self.assertIn(column, self.doc, f"boundary table is missing '{column}'")
def test_declares_itself_documentation_only(self):
self.assertIn("documentation only", self.doc.lower())
class ThreatModelConsistencyTests(unittest.TestCase):
"""Counts stated in prose must match the rows actually present."""
def setUp(self):
self.doc = _read(DOC_PATH)
def _declared_ids(self, prefix):
# Table rows begin '| CR1 |' / '| B3 |' / '| A2 |'.
return sorted(
{
int(m)
for m in re.findall(
r"^\|\s*%s(\d+)\s*\|" % prefix, self.doc, re.MULTILINE
)
}
)
def test_identifier_sequences_have_no_gaps(self):
for prefix, label in (
("A", "assets"),
("B", "boundaries"),
("CR", "credentials"),
):
ids = self._declared_ids(prefix)
self.assertTrue(ids, f"no {label} declared")
self.assertEqual(
list(range(1, len(ids) + 1)),
ids,
f"{label} identifiers must run 1..n with no gaps; got {ids}",
)
def test_stated_credential_count_matches_the_rows(self):
ids = self._declared_ids("CR")
match = re.search(r"(\d+)\s+credential(?:s)? in total", self.doc)
self.assertIsNotNone(match, "the credential inventory must state its own total")
self.assertEqual(
len(ids),
int(match.group(1)),
"stated credential total disagrees with the number of rows",
)
def test_every_boundary_is_owned_by_at_least_one_child(self):
"""Each boundary must be owned by a child *in the mapping table*.
Scanning the whole section would let a prose summary line ("Boundary
coverage: ... B5 (#936)") satisfy the assertion while the table row
that actually assigns the owner had been emptied — verified by
deliberately blanking a row and watching a whole-section check still
pass. Only table rows count.
"""
mapping_section = _section_body(self.doc, "Child-to-boundary mapping")
self.assertIsNotNone(
mapping_section, "child-to-boundary mapping section is missing"
)
rows = [
line
for line in mapping_section.splitlines()
if line.lstrip().startswith("|") and re.search(r"#93\d", line)
]
self.assertGreaterEqual(
len(rows), len(REQUIRED_CHILDREN), "mapping table has too few child rows"
)
mapped = set(re.findall(r"\bB(\d+)\b", "\n".join(rows)))
declared = {str(i) for i in self._declared_ids("B")}
unmapped = sorted(declared - mapped, key=int)
self.assertEqual(
[],
unmapped,
"boundaries with no owning child: " + ", ".join("B" + u for u in unmapped),
)
if __name__ == "__main__":
unittest.main()