feat(mcp): implement emergency break-glass MCP restart workflow (#664) #908
Open
jcwalker3
wants to merge 1 commits from
feat/issue-664-break-glass-restart into master
pull from: feat/issue-664-break-glass-restart
merge into: :master
:master
:docs/issue-930-remote-mcp-coupling-inventory
:fix/issue-927-pytest-config
:feat/issue-708-mcp-namespace-attachment
:fix/issue-892-author-bootstrap-deadlock
:fix/issue-686-detect-reject-manual-mcp
:feat/issue-707-cross-project-boundaries
:fix/issue-690-review-profile-switch-guard
:fix/issue-704-prevent-env-workspace-bindings
:fix/issue-700-durable-walls
:fix/issue-672-mcp-config-drift
:fix/issue-689-deterministic-mcp-namespace
:fix/issue-678-codex-mcp-reconnect
:feat/issue-649-sentry-console-correlation
:feat/issue-666-concurrent-mcp-restart-tests
:feat/issue-659-maintenance-drain-mode
:feat/issue-648-notifications-console
:fix/issue-670-direct-master-incident
:feat/issue-644-console-recovery
:feat/issue-650-providers-insights
:feat/issue-669-scoped-component-recovery
:docs/issue-668-mcp-ha-rolling-restart
:feat/issue-667-console-restart-controls
:feat/issue-665-restart-audit
:feat/issue-664-break-glass-restart
:feat/issue-645-linkage-console
:feat/issue-643-request-preview-initiate
:fix/issue-897-permission-stale-runtime-classification
:feat/issue-641-runtime-session-view
:feat/issue-663-restart-classes
:feat/issue-661-drain-proof-hard-gate
:fix/issue-854-semantic-container-exclusion
:issue-640
:fix/issue-682-starlette-httpx2
Labels
Clear labels
allocator
anti-stomp
architecture
bug
chore
codex
concurrency
contamination
control-plane
dashboard
database
design
documentation
enhancement
gitea
glitchtip
important
incident
incident-bridge
integration
jenkins
labels
leases
mcp
mcp-health
mcp-menu
multi-project
mutating
nice-to-have
observability
portability
preflight
protected-branch
queue
read-only
reconnect
recovery
refactor
release
reliability
resumable-review
reviewer
roadmap
safety
security
self-hosted
sentry
stale-runtime
status:blocked
status:in-progress
status:pr-open
status:ready
terminal-lock
testing
tracker
type:bug
type:feature
type:feature
type:guardrail
visibility
workflow
workflow-hardening
workflow-hardening
Controller-owned work allocator
Prevent concurrent LLM session stomping
Architecture / structural design
OpenAI Codex client / workflow session surface
Concurrent session safety
Workflow or session contamination incident
MCP control-plane coordination and allocation authority
MCP operational dashboard/queue view
Internal coordination storage (SQLite/Postgres)
Design / investigation, no implementation
Docs / runbooks
New feature or improvement
Gitea MCP workflow
GlitchTip integration
Operational or process incident requiring durable audit trail
Sentry-to-Gitea incident bridging
Integration testing
Jenkins integration
Label taxonomy management
Lease adopt/release/expire lifecycle
MCP server / tooling
MCP namespace and runtime health
MCP menu surface
Work spanning multiple monitoring projects or Gitea repos
Mutating action; requires gating
Observability, metrics, traces, error reporting
Cross-platform / portability
Shared preflight gates before mutation
Protected branch / stable-branch policy concern
Work queue visibility and allocation
Read-only, no mutation
MCP client reconnect/reload recovery path
Recovery paths for stale/foreign leases
Code refactor / restructure
Release / versioning
Reliability / failure handling
Persist and resume prepared review verdicts across sessions
Reviewer workflow tooling
Roadmap / umbrella issue
Safety rails and fail-closed mutation guards
Security / trust boundary
Self-hosted infrastructure integration
Sentry error monitoring integration
Stale backend daemon / runtime-vs-master parity failures
Issue is blocked
Issue is being worked on
Issue has an open pull request
Issue is ready for work
Terminal review lock (#332) path
Tests / test coverage
Issue tracker hygiene / meta
Bug or defect
Feature or enhancement
Feature or enhancement
Safety gate or guardrail
Workflow state visibility for LLMs/operators
Cross-tool workflow
LLM workflow coordination hardening
LLM workflow coordination hardening
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: Scaled-Tech-Consulting/Gitea-Tools#908
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #664
Summary
Implements the privileged emergency break-glass MCP restart workflow tool
gitea_break_glass_restartfor #664.reason(min 10 chars), exactconfirmationphraseI_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION, andimpact_ack=True(#664 AC2).[INCIDENT] Break-glass...) and immutable audit records (#664 AC3).reconciliation_required=True,reconciliation_tool=gitea_reconcile_after_restart) (#664 AC4).task_capability_map.pyand documented indocs/mcp-restart-coordinator.md.tests/test_issue_664_break_glass_restart.py(7/7 passed, 132/132 restart suite passed).Ref #652 #653 #655 #630 #658 #662 #664
Reviewer findings at head
c1ecadce8e52a067d278edc9d6ed510f3adb81cd. No formal verdict is recorded by this comment — the control plane permits one live review decision per MCP server run and this run already recorded one on PR #907. The verdict below will be recorded on the next run; treat this as the finding set, not the decision.The shape of the tool is right: gates ordered role → reason → confirmation → impact_ack, each returning a distinct
blocker_kind; the confirmation phrase is compared exactly after.strip();request_break_glass=Trueis a real parameter ofgitea_request_mcp_restartandaffected_sessionsis a real key of its payload, so the impact wiring works. The six findings below are about the authorization and audit properties, which are the ones #664 exists to guarantee.B1 — AC1 is a denylist, so any role outside four literals is authorized
gitea_mcp_server.py(break-glass body):Nothing after this verifies the caller is privileged. The docstring says "controller/admin/sysadmin ... is required" and
docs/mcp-restart-coordinator.mdrepeats it — both describe an allowlist; the code is a denylist over four strings._profile_role_kind(gitea_mcp_server.py:236-261) does not return only those five values. It returns any declaredrole/role_kindlowercased and passed through (only"control"substrings normalize tocontroller), and when nothing is declared it falls through to_role_kind, which returns"mixed"for a profile holding both approve/merge and author permissions (line 16432) and"limited"as its terminal fallback (line 16442)."mixed","limited", an empty string, and any future or misspelled role name all pass this gate with no privilege check performed. A"mixed"profile is strictly more capable than the four that are refused, and it is admitted.Invert it: authorize only the roles that are meant to hold this, and refuse everything else.
B2 — the env bypass is silent, contrary to the convention it copies
GITEA_BREAKGLASS_RESTART_AUTHORIZATIONnon-empty turns any of the four refused roles into an authorized caller. Env-var-as-authority is an established and sound pattern here —gitea_request_mcp_restartuses it deliberately (gitea_mcp_server.py:22695-22697: "a worker session cannot set an env var for an already-running daemon, so it cannot be self-asserted the way a tool argument could (#630/#710 F1 pattern)"), so the channel itself is not the objection.The objection is that this implementation drops the half of the pattern that makes it safe. The coordinator reports both
break_glass_requestedandbreak_glass_authorized, anddocs/mcp-restart-coordinator.md:134-136states the reason: "so a bypass is never silent." The break-glass payload has no equivalent field. On the bypass path it returnssuccess: Truewith"role": "author"and nothing anywhere in the response, the audit payload, or the incident issue body records that an ordinary role was admitted by env authority. An auditor reading the incident sees anauthorwho performed a break-glass and no indication of how that was permitted.Two smaller points on the same line: the value is never compared against any expected secret — any non-empty string authorizes, so
GITEA_BREAKGLASS_RESTART_AUTHORIZATION=nogrants access — and the doc sentence "Ordinary LLM worker roles ... are denied fail-closed. Privilegedcontrollerrole or explicitGITEA_BREAKGLASS_RESTART_AUTHORIZATIONis required" contradicts itself across its two halves.tests/test_issue_664_break_glass_restart.py:164-186asserts this bypass succeeds for anauthorrole. That test encodes the behavior as intended, which is why this is a design question rather than a slip: #664's non-goals say "Allowing LLM sessions break-glass," and AC1 says ordinary roles cannot invoke it. Either the bypass is dropped, or it is surfaced explicitly in the payload and the incident body and the AC is amended to describe what was built.B3 — the incident issue is created through a
gitea.readgateThe tool's only permission gate is
_profile_operation_gate("gitea.read"), andtask_capability_map.pydeclares both new entries with"permission": "gitea.read". It then creates a Gitea issue by callingapi_request("POST", f"{repo_api_url(h, o, r)}/issues", ...)directly.gitea_create_issuegates the identical operation ontask_capability_map.required_permission("create_issue"), which isgitea.issue.create(task_capability_map.py:11-14). So a profile that is forbidden from creating issues creates one through this path. The write is real, it lands under the session's own token, and the permission that exists to govern it is never consulted. Gate the incident write ongitea.issue.create, or route it through the tool that already does.B4 — the audit record is neither immutable nor proven
AC3 and the observability section require an immutable audit entry; the docs promise "Records an immutable audit log entry." The implementation calls
mcp_session_state.save_state(kind="break_glass_audit", ...), which resolves one deterministic file path per(kind, remote, org, repo, profile_identity, instance_id)and writes it under an exclusive lock (mcp_session_state.py:536-545). The second break-glass in the same scope overwrites the first record, and the same API deletes it outright when called withpayload=None(lines 548-554). That is last-write-wins mutable state, not an immutable event log.The sibling PR #909 (#665) emits
mcp.restart.*events append-only throughgitea_audit— that is the sink this needs.Separately,
"saved_audit": dict(saved_audit or audit_payload)substitutes the unsaved in-memory payload whensave_statereturns falsy, so the response is byte-identical whether or not the audit persisted. The field cannot be used as evidence the record exists.B5 — AC3's "always" is caller-optional and failure-silent
create_incident_issue: bool = Trueis a caller-supplied parameter. PassingFalseskips incident creation entirely while the tool still returnssuccess: Trueandbreak_glass_executed: True. AC3 is "Incident + audit always created" and the security note is "never silent."The failure path has the same shape:
A failed POST is captured into the payload and execution continues to a
success: Truereturn. #909 gets this right — its privileged apply fails closed when the audit sink write fails. Here, a break-glass whose incident never landed is reported as a fully successful break-glass.B6 —
break_glass_executed: Truewhen no restart is performedThe function body contains no process kill, no
subprocess, noexecv, noos._exit, and no call togitea_record_daemon_process_kill_attempt. It evaluates impact, writes state, creates an issue, and returns"break_glass_executed": not dry_run— unconditionallyTruein apply mode.If the intent is that this tool stays analysis-and-record-only like the #658 coordinator, that is defensible, but then the field asserts something that did not happen and an operator reading it will believe the emergency restart occurred. Either name it for what it does (
break_glass_recorded/restart_authorized) or wire it to the path that actually performs the restart. #664's problem statement is that emergency restart today "looks like pkill or unguarded process kill" — a tool that records the paperwork without performing or delegating the restart leaves the pkill in place.Non-blocking observations
tests/test_issue_664_break_glass_restart.py:22-40coversauthoronly.reviewer,mergerandreconcilerare untested, and no test drives a role outside the tuple, which is why B1 is invisible to the suite.create_incident_issue=Falseor the incident-creation exception path, so B5's two branches are unexercised.mock_save_state.assert_called_once()(line 161) proves the call happened, not that anything durable or immutable resulted — the same tautology class as a membership assertion against its own constant.restart_classis accepted as a free-form string and forwarded; whether it is well-formed is decided entirely by the coordinator downstream.Canonical PR State
STATE: PR-open
WHO_IS_NEXT: author
NEXT_ACTION: Make AC1 an allowlist, surface the env bypass in the payload and incident, gate the incident write on gitea.issue.create, move the audit to an append-only sink, and fail closed when the incident or audit write fails.
NEXT_PROMPT:
ISSUE: #664
RELATED_PRS: #908
REVIEW_STATUS: REQUEST_CHANGES
MERGE_READY: false
HEAD_SHA:
c1ecadce8eBLOCKERS: B1 AC1 role check is a denylist so mixed/limited/undeclared roles are authorized; B2 the env bypass is silent and its value is never compared against an expected secret, unlike the coordinator convention it copies; B3 the incident issue is written through a gitea.read gate, bypassing gitea.issue.create; B4 the audit uses overwrite-by-key session state, not an immutable log, and its success field is unfalsifiable; B5 incident creation is caller-optional and its failure is swallowed while still reporting success; B6 break_glass_executed is True although no restart is performed.
WHAT_HAPPENED: PR #908 was reviewed in full at head
c1ecadce8eagainst merge base76f293eb28— one commit, 412 insertions across gitea_mcp_server.py, task_capability_map.py, docs/mcp-restart-coordinator.md and tests/test_issue_664_break_glass_restart.py. Each authorization claim was checked against the function it depends on rather than against the docstring: the role domain was read out of _profile_role_kind and _role_kind, the permission convention out of gitea_create_issue and task_capability_map, the audit durability out of mcp_session_state.save_state, and the env-authority convention out of gitea_request_mcp_restart and the coordinator doc. Six findings and four lesser observations were found.WHY: #664 exists to stop emergency restart from being an unaudited pkill, so its value is entirely in the authorization and audit properties. Those are the ones that do not hold: the role gate admits any role it did not think to name, the privileged bypass leaves no trace, the incident write skips the permission that governs it, the audit record is overwritable and its persistence unprovable, and both the incident and the audit can fail or be skipped while the tool reports complete success. The input checks (AC2) and the reconciliation requirement (AC4) are correctly implemented.
VALIDATION: Static review at head
c1ecadce8eagainst merge base76f293eb28, in the branch worktree at that head. Two candidate findings were discarded during verification rather than reported: request_break_glass is a genuine parameter of gitea_request_mcp_restart (gitea_mcp_server.py:22577) so the impact call does not raise, and affected_sessions is a genuine key of the coordinator payload (restart_coordinator.py:374) so disrupted-session counting is not always zero. The env-var-authority mechanism was likewise checked against its existing use before being written up, and the finding was narrowed to the missing disclosure rather than the mechanism. Live head SHA before these findings:c1ecadce8e. Author pushes during the read: none. No test suite was executed at this head in this session and none is claimed; the author reports 7/7 in the new module and 132/132 in the restart suite, and B1/B5's observations explain why those results are consistent with these findings.LAST_UPDATED_BY: sysadmin (prgs-reviewer)
NATIVE_REVIEW_PROOF: findings posted via gitea_create_issue_comment on native MCP namespace gitea-reviewer, profile prgs-reviewer, identity sysadmin, at head
c1ecadce8e. No formal review verdict was recorded by this call. No offline, import, or helper path was used.[THREAD STATE LEDGER]
what is true now
PR #908 is in open state at head
c1ecadce8eagainst master, and reports no conflict against that base.Server-side decision state: no review decision of any kind is recorded on PR #908; this comment is findings only.
Local verdict/state: REQUEST_CHANGES, six blockers, verified statically against the live head.
what changed
Nothing on the PR itself. A complete finding set now exists on the thread where previously there was none.
what is blocked
Blocker classification: code blocker
Recording the formal verdict is blocked by the control plane's one-live-review-mutation-per-server-run rule; this run already recorded REQUEST_CHANGES on PR #907. That is a process constraint, not a defect in this PR. The six code blockers are listed under BLOCKERS above.
who/what acts next
Next actor: author
Required action: Address B1-B6 as described in NEXT_PROMPT, with a regression test per finding.
Do not do: Do not read the absence of a formal verdict as absence of blocking feedback. Do not treat the 7/7 and 132/132 passing runs as evidence against B1 or B5 — no test drives a role outside the refused tuple, and neither the incident opt-out nor the incident-failure path is exercised.
repo: Scaled-Tech-Consulting/Gitea-Tools
pr: #908
issue: #664
reviewer_identity: sysadmin
profile: prgs-reviewer
session_id: 52249-1dc788b0892e
worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/review-pr908-664
phase: claimed
candidate_head:
c1ecadce8etarget_branch: master
target_branch_sha: none
last_activity: 2026-07-25T23:19:46Z
expires_at: 2026-07-25T23:29:46Z
blocker: none
Review verdict: REQUEST_CHANGES at head
c1ecadce8e.This records the formal decision for the finding set posted to this thread as comment 17069 at the same head. The head has not moved since — the PR is still at
c1ecadce8e— and every finding below was re-checked at that SHA before this verdict. B1, B4, B5 and B6 were additionally reproduced by invoking the tool directly at this head rather than reasoned from the diff, and two further blockers (B7, B8) surfaced from those runs.The shape of the tool is right: gates ordered role → reason → confirmation → impact_ack, each returning a distinct
blocker_kind; the confirmation phrase is compared exactly after.strip();request_break_glass=Trueis a real parameter ofgitea_request_mcp_restartandaffected_sessionsis a real key of its payload, so the impact wiring works. The findings below are about the authorization and audit properties, which are the ones #664 exists to guarantee.B1 — AC1 is a denylist, so any role outside four literals is authorized
gitea_mcp_server.py:22866:Nothing after this verifies the caller is privileged. The docstring says "controller/admin/sysadmin ... is required" and
docs/mcp-restart-coordinator.mdrepeats it — both describe an allowlist; the code is a denylist over four strings._profile_role_kind(gitea_mcp_server.py:236-261) does not return only those five values. It returns any declaredrole/role_kindlowercased and passed through (only"control"substrings normalize tocontroller), and when nothing is declared it falls through to_role_kind, which returns"mixed"for a profile holding both approve/merge and author permissions (line 16432) and"limited"as its terminal fallback (line 16442).Reproduced at this head with the real
_profile_role_kindin the path — onlyget_profile, the permission gate, the impact evaluator and the identity lookup were stubbed:The last line is the sharpest form of it: a profile declaring no role and holding only
gitea.read— the least privileged shape the system has — resolves to"limited"and is admitted to the most privileged operation the system exposes. A"mixed"profile is strictly more capable than the four that are refused, and it is admitted too.Invert it: authorize only the roles meant to hold this, and refuse everything else, including an unrecognized or absent role.
B2 — the env bypass is silent, contrary to the convention it copies
GITEA_BREAKGLASS_RESTART_AUTHORIZATIONnon-empty turns any of the four refused roles into an authorized caller. Env-var-as-authority is an established and sound pattern here —gitea_request_mcp_restartuses it deliberately (gitea_mcp_server.py:22695-22697: "a worker session cannot set an env var for an already-running daemon, so it cannot be self-asserted the way a tool argument could (#630/#710 F1 pattern)") — so the channel itself is not the objection.The objection is that this implementation drops the half of the pattern that makes it safe. The coordinator reports both
break_glass_requestedandbreak_glass_authorized, anddocs/mcp-restart-coordinator.md:134-136states the reason: "so a bypass is never silent." The break-glass payload has no equivalent field. On the bypass path it returnssuccess: Truewith"role": "author"and nothing in the response, the audit payload, or the incident issue body records that an ordinary role was admitted by env authority. An auditor reading the incident sees anauthorwho performed a break-glass and no indication of how that was permitted.Two smaller points on the same line: the value is never compared against any expected secret — any non-empty string authorizes, so
GITEA_BREAKGLASS_RESTART_AUTHORIZATION=nogrants access — and the doc sentence "Ordinary LLM worker roles ... are denied fail-closed. Privilegedcontrollerrole or explicitGITEA_BREAKGLASS_RESTART_AUTHORIZATIONis required" contradicts itself across its two halves.tests/test_issue_664_break_glass_restart.py:164-186asserts this bypass succeeds for anauthorrole. That test encodes the behavior as intended, which is why this is a design question rather than a slip: #664's non-goals say "Allowing LLM sessions break-glass," and AC1 says ordinary roles cannot invoke it. Either the bypass is dropped, or it is surfaced explicitly in the payload and the incident body and the AC is amended to describe what was built.B3 — the incident issue is created through a
gitea.readgateThe tool's only permission gate is
_profile_operation_gate("gitea.read")atgitea_mcp_server.py:22829, andtask_capability_map.py:542-549declares both new entries with"permission": "gitea.read". It then creates a Gitea issue by callingapi_request("POST", f"{repo_api_url(h, o, r)}/issues", ...)directly (line 22972).gitea_create_issuegates the identical operation ontask_capability_map.required_permission("create_issue"), which isgitea.issue.create(task_capability_map.py:11-14). So a profile forbidden from creating issues creates one through this path. The write is real, it lands under the session's own token, and the permission that exists to govern it is never consulted. Gate the incident write ongitea.issue.create, or route it through the tool that already does.B4 — the audit record is neither immutable nor proven
AC3 and the observability section require an immutable audit entry; the docs promise "Records an immutable audit log entry." The implementation calls
mcp_session_state.save_state(kind="break_glass_audit", ...)atgitea_mcp_server.py:22947-22956, which resolves one deterministic file path per(kind, remote, org, repo, profile_identity, instance_id)and writes it under an exclusive lock (mcp_session_state.py:536-545).Reproduced at this head, two invocations into a scratch state dir:
The first record's reason is gone. One slot per profile, last write wins, no history — so the trail retains exactly one break-glass event and silently discards every earlier one. The same API deletes the file outright when called with
payload=None(mcp_session_state.py:548-554). That is mutable state, not an immutable event log.The sibling PR #909 (#665) emits
mcp.restart.*events append-only throughgitea_audit—build_eventatgitea_audit.py:163andwrite_eventatgitea_audit.py:199, which appends one JSON line per event and never raises into its caller. That is the sink this needs.Separately,
"saved_audit": dict(saved_audit or audit_payload)substitutes the unsaved in-memory payload whensave_statereturns falsy, so the response is byte-identical whether or not the audit persisted. The field cannot serve as evidence the record exists.B5 — AC3's "always" is caller-optional and failure-silent
create_incident_issue: bool = Trueis a caller-supplied parameter. PassingFalseskips incident creation entirely while the tool still returnssuccess: Trueandbreak_glass_executed: True. AC3 is "Incident + audit always created" and the security note is "never silent."The failure path has the same shape (
gitea_mcp_server.py:22984-22985):Reproduced at this head with the issue-creation call raising:
So the one artifact that makes a break-glass reviewable after the fact can fail entirely while the caller is told the operation completed. Combined with B4, a Gitea outage during a break-glass leaves a single overwritable local file as the only trace, and the payload asserts otherwise. #909 gets this right — its privileged apply fails closed when the audit sink write fails. Fail closed on both branches here.
B6 —
break_glass_executed: Truewhen no restart is performedThe function body contains no process kill, no
subprocess, noexecv, noos._exit, and no call togitea_record_daemon_process_kill_attempt. It evaluates impact, writes state, creates an issue, and returns"break_glass_executed": not dry_runatgitea_mcp_server.py:22991— unconditionallyTruein apply mode. The impact evaluation it delegates to is analysis-only:gitea_request_mcp_restartsetspayload["apply_supported"] = Falseat line 22752 and its docstring states it never restarts a process.If the intent is that this tool stays analysis-and-record-only like the #658 coordinator, that is defensible, but then the field asserts something that did not happen, and the incident it opens is titled
[INCIDENT] Break-glass MCP restart invoked by <identity>, which a later reconciler will read as evidence a restart was forced. Either name the field for what it does (break_glass_recorded/restart_authorized) and match the incident text, or wire it to the path that performs or delegates the restart. #664's problem statement is that emergency restart today "looks like pkill or unguarded process kill" — recording the paperwork without performing or delegating the restart leaves the pkill in place.B7 — a dry run writes the durable record and destroys the previous one
gitea_mcp_server.py:22948callssave_statebefore theif create_incident_issue and not dry_run:branch at line 22958, so the write happens on every accepted invocation includingdry_run=True. The docs added by this PR describe dry-run as an evaluation mode, andtests/test_issue_664_break_glass_restart.py:98-124asserts a dry run produces a preview "without live execution or incident creation."Reproduced at this head with
dry_run=True:Given B4's single-slot semantics this is worse than a stray file: a dry-run preview overwrites the durable record of a real break-glass that preceded it, so the cheapest and most repeatable call in the tool is the one that erases the audit trail. Move the write to the apply path, or give dry-run records a distinct kind that cannot collide.
B8 — operator free text is stored and published without redaction
gitea_mcp_server.py:22936places the rawclean_reasoninto the audit payload, and lines 22960-22966 interpolate it into the incident issue body, which is created on the Gitea instance. Neither path applies_redact; the only redaction in the whole function is on the exception string at line 22985. The reproduction under B4 confirms the stored value is verbatim.This is a break-glass path, invoked under time pressure, where pasting a raw error or a connection string into
reasonis a realistic thing for an operator to do.gitea_audit.build_eventalready redacts exactly this field (gitea_audit.py:187:"reason": _redact_str(reason) if reason else reason), which is a second reason to route the event through that sink.Non-blocking observations
tests/test_issue_664_break_glass_restart.py:22-40coversauthoronly.reviewer,mergerandreconcilerare untested, and no test drives a role outside the tuple, which is why B1 is invisible to the suite. Every one of the seven tests stubs_profile_role_kind(lines 27, 47, 66, 85, 103, 134, 170), so the real resolver is never exercised.create_incident_issue=Falseor the incident-creation exception path, so B5's two branches are unexercised.mock_save_state.assert_called_once()(line 161) proves the call happened, not that anything durable or immutable resulted — the same tautology class as a membership assertion against its own constant.restart_classis accepted as a free-form string and forwarded; whether it is well-formed is decided entirely by the coordinator downstream.docs/mcp-restart-coordinator.mdgains a trailing blank line at EOF; harmless, but unrelated to the change.Canonical PR State
STATE: PR-open
WHO_IS_NEXT: author
NEXT_ACTION: Make AC1 an allowlist, surface the env bypass in the payload and incident, gate the incident write on gitea.issue.create, move the audit to an append-only sink, keep dry-run out of the durable record, redact operator text, and fail closed when the incident or audit write fails.
NEXT_PROMPT:
ISSUE: #664
BASE: master
HEAD: feat/issue-664-break-glass-restart
HEAD_SHA:
c1ecadce8eRELATED_PRS: #908
REVIEW_STATUS: REQUEST_CHANGES
MERGE_READY: no — eight open blockers across the authorization gate, the bypass disclosure, the incident permission, the audit sink, incident failure handling, execution reporting, dry-run durability, and redaction
BLOCKERS: B1 AC1 role check is a denylist so mixed/limited/undeclared roles are authorized; B2 the env bypass is silent and its value is never compared against an expected secret, unlike the coordinator convention it copies; B3 the incident issue is written through a gitea.read gate, bypassing gitea.issue.create; B4 the audit uses overwrite-by-key session state, not an immutable log, and its success field is unfalsifiable; B5 incident creation is caller-optional and its failure is swallowed while still reporting success; B6 break_glass_executed is True although no restart is performed anywhere; B7 a dry run writes the durable audit record and clobbers the prior one; B8 operator reason text is stored and published with no redaction.
SUPERSEDES: none
SUPERSEDED_BY: none
WHAT_HAPPENED: PR #908 was reviewed in full at head
c1ecadce8eagainst merge base76f293eb28— one commit, 412 insertions across gitea_mcp_server.py, task_capability_map.py, docs/mcp-restart-coordinator.md and tests/test_issue_664_break_glass_restart.py. The finding set was posted to this thread as comment 17069 at that head when the control plane had no review mutation left for the run; this call records the formal decision for it, at the same unchanged head, after re-reading every cited line. Each authorization claim was checked against the function it depends on rather than against the docstring: the role domain out of _profile_role_kind and _role_kind, the permission convention out of gitea_create_issue and task_capability_map, the audit durability out of mcp_session_state.save_state and its path resolver, the append-only alternative out of gitea_audit, and the env-authority convention out of gitea_request_mcp_restart and the coordinator doc. B1, B4, B5 and B6 were then reproduced by invoking the tool directly at this head, which surfaced B7 and B8. Eight blockers and five lesser observations were found.WHY: #664 exists to stop emergency restart from being an unaudited pkill, so its value is entirely in the authorization and audit properties. Those are the ones that do not hold: the role gate admits any role it did not think to name — including the least privileged profile shape in the system, confirmed by invocation — the privileged bypass leaves no trace, the incident write skips the permission that governs it, the audit record is overwritable by the tool's own dry run and its persistence unprovable, operator text reaches a Gitea issue unredacted, and both the incident and the audit can fail or be skipped while the tool reports complete success on an execution that never occurred. The input checks (AC2) and the reconciliation requirement (AC4) are correctly implemented.
VALIDATION: Static review plus direct invocation at head
c1ecadce8eagainst merge base76f293eb28, in the bound worktree branches/review-pr908-664, confirmed clean by git status --porcelain --untracked-files=all. The head is unchanged from the head at which comment 17069 was written; no author push occurred between that comment and this verdict. tests/test_issue_664_break_glass_restart.py was executed at this head — 7 passed — reproducing the author's stated result; that outcome is consistent with these findings rather than in tension with them, since every test stubs _profile_role_kind and none exercises the incident opt-out, the incident-failure branch, or a role outside the refused tuple. B1 was reproduced across role values limited, mixed, observer and absent, each returning success=True with no blocker_kind. B4 and B7 were reproduced by two dry-run invocations into a scratch session-state dir, showing one file written on the dry run and the earlier reason absent after the second. B5 was reproduced by making the issue-creation call raise. The single-slot path was confirmed by resolving mcp_session_state.state_file_path for kind break_glass_audit. Two candidate findings were discarded during verification rather than reported: request_break_glass is a genuine parameter of gitea_request_mcp_restart (gitea_mcp_server.py:22577), and affected_sessions is a genuine key of the coordinator payload (restart_coordinator.py:374). The env-var-authority mechanism was likewise checked against its existing use before being written up, and B2 was narrowed to the missing disclosure rather than the mechanism. The PR reports no conflict against its base. Live head SHA before this verdict:c1ecadce8e. Author changes during the review: none. No full-suite run was performed at this head and none is claimed.LAST_UPDATED_BY: sysadmin (prgs-reviewer)
NATIVE_REVIEW_PROOF: gitea_submit_pr_review via native MCP namespace gitea-reviewer, profile prgs-reviewer, identity sysadmin, expected_head_sha
c1ecadce8e. No offline, import, or helper path was used.[THREAD STATE LEDGER]
what is true now
PR #908 is in open state at head
c1ecadce8eagainst master, and reports no conflict against that base.Server-side decision state: this REQUEST_CHANGES verdict is the first review decision recorded on PR #908; the earlier comment 17069 carried the finding set and explicitly recorded no decision.
Local verdict/state: REQUEST_CHANGES, eight blockers, four of them reproduced by direct invocation at the live head.
what changed
The review decision moved from a finding set held on the comment thread to a recorded server-side verdict on PR #908, at the same head, with two additional blockers (B7, B8) that the invocation runs surfaced. No repository content was altered by this review: no branch, worktree, or file in the tree was modified, and the reviewer lease is the only other server-side artifact touched.
what is blocked
Blocker classification: code blocker
B1 —
gitea_mcp_server.py:22866refuses only four role literals, so"mixed","limited", an empty string, and any declared role string are authorized with no privilege check. B2 — the env bypass is reported nowhere in the payload, audit, or incident, unlike the coordinator convention atdocs/mcp-restart-coordinator.md:134-136. B3 — the incident write is gated ongitea.readwhilegitea_create_issuerequiresgitea.issue.create. B4 —mcp_session_state.save_stateis overwrite-by-key and deletable, andsaved_audit or audit_payloadmasks a failed save. B5 — incident creation is caller-optional and its failure is swallowed intosuccess: True. B6 —break_glass_executedisnot dry_runalthough no code path performs a restart. B7 — the durable audit write happens on the dry-run path and clobbers the prior record. B8 — the operatorreasonis stored and published unredacted.who/what acts next
Next actor: author
Required action: Invert the AC1 gate into a privileged-role allowlist that refuses unrecognized or absent roles; surface the env bypass in the payload and the incident body; gate the incident write on
gitea.issue.createor route it throughgitea_create_issue; move the audit to the append-onlygitea_auditsink and drop thesaved_audit or audit_payloadfallback; fail closed when the incident or audit write fails and when the caller opts out; keep the durable write off the dry-run path; redact the operator reason before storing and before publishing; and reconcilebreak_glass_executedand the incident title with what the tool actually performs. Add a regression test per blocker, including a role-deny matrix and at least one role outside the refused tuple, without stubbing_profile_role_kind.Do not do: Do not close B1 by adding more names to the refusal tuple — the defect is the direction of the check, and any list of refused roles leaves unrecognized values authorized. Do not report the env-var channel itself as the defect; it is a sanctioned pattern here and the finding is the missing disclosure. Do not treat the 7/7 and 132/132 passing runs as evidence against B1 or B5 — no test drives a role outside the refused tuple, every test stubs the role resolver, and neither the incident opt-out nor the incident-failure path is exercised.
repo: Scaled-Tech-Consulting/Gitea-Tools
pr: #908
issue: #664
reviewer_identity: sysadmin
profile: prgs-reviewer
session_id: 52249-1dc788b0892e
worktree: /Users/jasonwalker/Development/Gitea-Tools/branches/review-pr908-664
phase: released
candidate_head:
c1ecadce8etarget_branch: master
target_branch_sha: none
last_activity: 2026-07-25T23:22:00Z
expires_at: 2026-07-25T23:32:00Z
blocker: manual-release
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.