Skip to content

fix(mcp): withhold Allow unless the consent page holds a served ceiling - #21254

Open
vsai12 wants to merge 9 commits into
mainfrom
feat/mcp-consent-hardening
Open

fix(mcp): withhold Allow unless the consent page holds a served ceiling#21254
vsai12 wants to merge 9 commits into
mainfrom
feat/mcp-consent-hardening

Conversation

@vsai12

@vsai12 vsai12 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

The hole

Every failure of the consent page's policy read landed it on the generic "access
your account" card with Allow enabled, and the POST then succeeded. A
transient outage was enough. The page issued an MCP grant without disclosing what
the grant was worth.

Live, on origin/main, against a workspace whose real ceiling is READ_ONLY,
with only the GetMCPInfo request blocked:

Authorization Request
This application will be able to: Access your Bytebase account with your permissions
[Deny] [Allow]

That sentence is not true of any session this server issues. Every grant is an
MCP grant, and the workspace ceiling decides what one is worth.

The same refusal also killed the workspace MCP settings drawer at exactly the
moment an admin needed it — while repairing an unreadable ceiling.

Both come from one line: GetMCPInfo refused whole on
MCPCeilingUnreadable / MCPCeilingUnserved.

Why refusing was wrong

The mode contents do not come from the stored row. modes, methods and
engines are resolved from the compiled descriptors and the enforcement code,
and they are the whole reason either surface calls this method. The refusal
withheld the comparison from the one admin who has to repair the row, and left
the consent page with no policy to disclose.

(capability and ignore_masking_exemptions do come from the row — that is
what carries the state to the client. An earlier draft of this comment claimed
the response held nothing from the row at all; pre-review caught it.)

What changed

Backend. The three refusing ceilings are answered. A new OUTPUT_ONLY
MCPInfo.policy_unreadable carries the one state the number cannot, following
BOT-100's data-not-status-code precedent. It is named for the consequence: a
wrong JSON type on any field fails the whole unmarshal, so a malformed
ignore_masking_exemptions sets it over a perfectly readable capability. It is
therefore wider than MCPSetting.capability_unreadable and not interchangeable
with it — see "One field, two row shapes" below. A ceiling that parsed but no
mode serves needs no field: the number survives on capability and modes has
no row for it.

A failed read still refuses, with CodeUnavailable. It is the one verdict with
no ceiling to describe, and answering with an empty one would read as a stored
value nobody can resolve — which an admin repairs rather than a retry clearing.

Frontend. readConsentCeiling reads the response into five states, and
Allow renders in exactly one: a ceiling the server serves and this bundle can
name. The disclosure is the consent, so approving against an unnamed policy is
agreement to nothing.

state when Allow way out
mode server serves it, bundle names it yes
unknown the read failed or timed out no Check again
unreadable the row resolves to no ceiling no repair the policy
unserved resolved, no mode serves it no admin picks a supported value
outdated server serves it, bundle has no name no reload

The generic card is deleted rather than demoted. refuseConsentByCeiling
(backend/api/oauth2/consent_audit.go:63) runs on every allow POST, so no
non-MCP grant flow exists — the card had no producing flow and survived only as
a failure fallback.

Leaving Allow to fail server-side was not an option: only two of the four
undisclosed states also refuse the POST, and a failed read on this page says
nothing about the read the POST makes for itself.

Settings. The drawer feeds from the static half, so it works while an admin
repairs an unreadable ceiling. That is BOT-106's first half and it falls out of
the backend change; a test pins it.

Evidence

Every test below was run RED against the pre-change code first.

claim receipt
unreadable ceiling is described, not refused TestGetMCPInfoHandler/a ceiling this build cannot read is described, not refused
unserved ceiling likewise .../a ceiling no mode serves is described by the value nobody serves
never-configured workspace stays readable .../a workspace that never configured MCP
a failed read offers no grant, and retry recovers OAuth2ConsentPage.test.tsxa failed policy read offers no grant and can be retried
unreadable ceiling offers no grant an unreadable ceiling says so and offers no grant
unserved ceiling offers no grant a ceiling no mode serves says so and offers no grant
a ceiling this bundle cannot name asks for a reload a ceiling this page cannot name asks for a reload, not a grant
dismissing tells the waiting client, not history dismissing an undisclosed policy denies the request
drawer survives the repair path MCPAccessPolicySection.test.tsxthe mode-contents drawer is available while repairing an unreadable ceiling

Live check on the running dev server, Allow counted by accessible name:

state seeded row GetMCPInfo heading Allow buttons
a READ_ONLY served Authorization Request 1
b READ_SIDEWAYS served, unreadable This workspace's MCP policy cannot be read 0
c READ_ONLY blocked The MCP policy could not be checked 0
RED READ_ONLY blocked, origin/main page Authorization Request 1

Clicking Check again in state (c) with the block lifted returns the ceiling
card and Allow, without a reload.

Gates

buf format / lint / generate · gofmt · golangci-lint run ./... → 0
issues · go build ./... · go test -p=8 -timeout 30m ./backend/... → 107
packages green · pnpm --dir frontend fix / check / type-check / test
3832 passed.

Three later local runs each hit one flake in backend/tests — a different
test every time (TestTransactionMode, TestTenantBackfill,
TestSQLQueryStopOnError), every one failing with port "<port>/tcp" not found from testcontainers, and the two I re-ran alone both passed. Local Docker
had accumulated 66 containers against an 8.2 GB limit. The only non-comment
production change on this branch is inside GetMCPInfo's ceiling switch, which
none of those three tests reaches. Left to CI's amd64 lane, which runs this
suite in a clean container environment.
Locale keys added to all five files — 16 each, verified identical, with the
deleted generic-card keys gone from every one. The four dynamic reason prefixes
are registered in check-react-i18n.mjs.

scripts/comment-share.sh reports 33% (80 added comment lines / 159 code).
Two re-read passes cut 31 lines of prose against the three comment types; what
remains is constraints and external facts. Gross overstates it — 33 of those 79
lines replace comments this change made false (the old "GetMCPInfo refuses
outright" claims in MCPModeContentsSheet and MCPAccessPolicySection). Net
growth is +46.

The script itself had a hole and this PR's first measurement was wrong because
of it: its pathspec excluded *.test.ts but not *.test.tsx, so test comments
counted as production. Fixed in the workspace copy.

One field, two row shapes — and why it stays a bool

Codex found that this field is true for two different rows: a
capability key this build cannot resolve ("READ_ONLYY", null, ""), which
an admin repairs in the settings UI, and a value of the wrong JSON type
({}, true, 1.5, []), which fails the whole unmarshal and needs an
operator to correct the stored value. GetSetting refuses the second kind
outright, so the settings page correctly offers no Edit action for it — while
this page told the user "an admin sets the policy again". Reproduced end to end.

The defect was that sentence, not the field. policy_unreadable means
"this build cannot resolve a ceiling from the stored row", which is true of both.
Walking the consumers, nothing branches on the difference: Allow is withheld
either way, the gate/token/connection doors refuse either way, and
IsPolicy() already returns true for MCPCeilingUnreadable, so both are
already audited as policy denials. The only surface that distinguishes them is
the settings page, and it learns that from GetSetting failing, not from here.
One bit of consequence, so one bool.

Three changes rather than a new representation:

  • The unreadable line is now operator-neutral — "until this workspace's MCP
    policy is repaired" — true for every row that reaches the card. Deliberately
    passive: this page cannot know which actor applies, and the previous wording
    picked the wrong one for four row shapes.
  • The field is renamed policy_unreadable and its doc no longer claims it is
    the "same state, same representation" as MCPSetting.capability_unreadable.
    Codex found the old name asserts something false: given
    {"capability":"READ_ONLY","ignoreMaskingExemptions":[]} the capability is
    readable and the field is still true, because one bad field fails the whole
    unmarshal. Renaming was free — the field is unreleased.
  • {}, true, 1.5, [] and two malformed-sibling rows are pinned through
    GetMCPInfo. That branch had no coverage, which is why it was missed. Mutation-checked: making the store
    stop classifying unparseable rows as unreadable fails the new subtest and
    nothing else.

A three-valued resolution enum was designed and rejected — tracked in BOT-115
with the analysis, including why mapping malformed rows onto
MCPCeilingUnavailable would have been worse (it says "Retry shortly" for a
permanent condition and drops the denial audit row).

Known limitation

Dismissing an undisclosed policy posts action=deny, and authorize.go:121
answers that before the ceiling is ever classified — so the waiting client gets
access_denied with the description "user denied the request". For the
unknown state nobody denied anything, and the same condition reached through
the POST returns temporarily_unavailable. RFC 6749 admits access_denied for
an authorization-server refusal, so this is imprecise rather than wrong, but the
description string is false and the two doors disagree. Fixing it needs the
consenting user resolved before the deny branch, which is a different mechanism
from the one this PR changes. Flagged rather than folded in.

Withholding Allow also removes the audit row those attempts used to produce:
before, an unreadable or unserved ceiling reached the POST and refuseConsent
wrote an MCPConsentApprove denial. MCPConsentDisabled already carries this
gap for the disabled screen; this extends it to two more states. Same fix, same
place.

Not in this PR

The MCPSetting etag. A provenance check found the design-doc §17 requirement
had no codebase precedent to copy and no producing flow; it is filed as BOT-113
as a settings-wide decision and is not a flip prerequisite. No flip, no isDev
change, no seed (BOT-108), no SDK work (BYT-10061), no gate/clamp/masking
changes.

Closes BOT-106.

vsai12 and others added 2 commits August 26, 2026 12:54
GetMCPInfo refused whole on an unreadable or unserved ceiling. Nothing in
its response comes from the stored row — modes, methods and engines are
resolved from the compiled descriptors and the enforcement code — so the
refusal withheld the mode comparison from the admin repairing the row, and
left the consent page with no policy to disclose at the moment someone
approves a client.

The three refusing ceilings are answered now. A new OUTPUT_ONLY
MCPInfo.capability_unreadable carries the one state the number cannot: the
same field and the same representation MCPSetting.capability_unreadable
already uses on the settings API (BOT-100's data-not-status-code
precedent). A ceiling that parsed but no mode serves needs no field — the
number survives on capability and modes has no row for it.

A failed read still refuses. It is the one verdict with no ceiling to
describe, and answering with an empty one would read as a stored value
nobody can resolve, which an admin repairs rather than a retry clearing it.
The store error text stays in the log for the reason the existing comment
records.

Part of BOT-106.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every failure of the ceiling read landed the consent page on the generic
"access your account" card with Allow enabled, and the POST then succeeded.
A transient outage was enough: the page issued a grant with no disclosure of
what the grant was worth.

The page now reads the response into one of five states and offers Allow in
exactly one of them — a ceiling the server serves and this bundle can name.
The disclosure is the consent, so approving against an unnamed policy is
agreement to nothing. The other four say what is wrong and what to do about
it: retry for a failed read, reload for a ceiling a newer release added, ask
an admin for the two only an admin can fix.

The generic card is gone rather than demoted. Every grant this server issues
is an MCP grant — refuseConsentByCeiling runs on every allow POST — so it
had no producing flow and existed only as a failure fallback.

Leaving Allow to fail server-side was not an option: only two of the four
undisclosed states also refuse the POST, and a failed read on this page says
nothing about the read the POST makes for itself.

The settings page picks up the other half of BOT-106 for free. The
mode-contents drawer feeds from the static half of GetMCPInfo, so it is
available while an admin repairs an unreadable ceiling — which is exactly
when they open it.

Closes BOT-106.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vsai12
vsai12 requested review from a team, RainbowDashy, d-bytebase and ecmadao as code owners August 26, 2026 20:03
@cla-bot cla-bot Bot added the cla-signed label Aug 26, 2026
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Proto linter / lint-protos (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed⏩ skippedAug 27, 2026, 4:40 AM

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 35a6194e1d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread frontend/src/routes/auth/MCPConsentUndisclosed.tsx
vsai12 and others added 2 commits August 26, 2026 14:36
The line that explains why there is no Allow was a styled div inside the
details panel. It arrives after the policy read settles, so a screen reader
had nothing to announce: a div renders identically and says nothing.

It moves to the shared Alert, which carries role="alert", and sits beside the
details panel rather than inside it — the placement MCPConsentCeiling already
uses for its write caution. The panel keeps what is genuinely a detail.

frontend/AGENTS.md: "Use Badge not styled spans, Alert not custom callout
divs". Raised by Codex on #21254.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
retryFor's `unknown` arm returned retryCeiling directly. That function is
async, and the prop it lands on is typed `() => void`, so the promise was
never awaited (SonarCloud S6544). readCeiling swallows its own failures, so
the fix discards the promise explicitly rather than routing a rejection that
cannot happen.

TypeScript allows the assignment — `() => Promise<void>` is assignable to
`() => void` — so neither type-check nor Biome catches this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vsai12
vsai12 marked this pull request as draft August 26, 2026 22:38
…iver

capability_unreadable is true for two row shapes. A capability key this build
cannot resolve ("READ_ONLYY", null, "") unmarshals away to unset and an admin
repairs it in the settings UI. A value of the wrong JSON type ({}, true, 1.5,
[]) fails the whole unmarshal, and GetSetting refuses that row outright — so
the settings page offers no Edit action and says an operator must correct the
stored value. This page told the user an admin could set the policy again,
which is false for the second kind.

The defect is that sentence, not the field: capability_unreadable means this
build cannot resolve a ceiling from the row, and that holds for both. No
consumer branches on the difference — Allow is withheld either way, the gate,
token and connection doors refuse either way, and IsPolicy() already returns
true for MCPCeilingUnreadable so both are already audited as policy denials.
The only surface that distinguishes them learns it from GetSetting failing.

The line is now operator-neutral, and passive on purpose: this page cannot
know which actor applies, and naming one picked the wrong actor for four row
shapes.

The field's doc no longer claims it is the same state and representation as
MCPSetting.capability_unreadable. It is wider — that field never describes a
row GetSetting refuses — and the doc now says a client cannot infer the repair
path from it.

{}, true, 1.5 and [] are pinned through GetMCPInfo. That branch had no
coverage, which is why this was missed. Mutation-checked: making the store
stop classifying unparseable rows as unreadable fails the new subtest alone.

A three-valued resolution enum was designed and rejected; BOT-115 records the
analysis. Raised by Codex on #21254.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vsai12
vsai12 marked this pull request as ready for review August 26, 2026 23:13
…eadable

capability_unreadable claimed the capability is the unreadable part. It is one
case of several: protojson fails the whole unmarshal on a wrong JSON type
anywhere in the row, so

  {"capability":"READ_ONLY","ignoreMaskingExemptions":[]}

has a perfectly readable capability and still sets the field. The name asserted
something false about four row shapes and, sharing a name with
MCPSetting.capability_unreadable while covering strictly more rows, invited a
generated-client reader to treat the two as interchangeable.

Renamed to policy_unreadable, which names what the caller can act on: no
ceiling can be resolved from this row. Free to do — field 8 is unreleased,
3.21.1 predates it.

MCPSetting.capability_unreadable is untouched. GetSetting refuses a row that
does not unmarshal, so that field only ever describes rows that parsed and its
name is accurate for its domain.

The doc drops its "three rows reach it" enumeration, which was wrong by one and
is the reason this was missed: the malformed-sibling path is not about the
capability at all. Two such rows join the handler test.

Raised by Codex on #21254.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread frontend/src/components/mcp/mcpPolicy.ts
Comment thread proto/v1/v1/workspace_service.proto Outdated
Comment thread proto/v1/v1/workspace_service.proto Outdated
… state

The field said nothing capability did not. On a 200 response the verdicts are
disjoint over it: Serves carries a real ceiling, DISABLED carries DISABLED,
Unserved carries the raw number it could not serve, and only an unreadable row
arrives unspecified. A workspace that never configured MCP resolves to
READ_WRITE, so unspecified never means unconfigured here.

So two fields carried one bit, and two sources for one bit can disagree.
readConsentCeiling derives it in one line instead.

The invariant that replaces the field is documented on MCPInfo.capability,
commented at the frontend site relying on it, and pinned twice: a backend
assertion that never-configured resolves READ_WRITE, and a frontend test that
fails if the unspecified branch is removed.

This also removes the reason the field's doc had grown to 33 lines — it was
long because the field's domain was subtle, and the domain was subtle only
because it duplicated something unambiguous. Those lines were being copied
verbatim into four generated artifacts.

Raised independently by d-bytebase ("is this really necessary") and ecmadao
(second option in his review note). Field 8 is reserved rather than reused.
Moving the whole decision server-side is BOT-118; it can only cover three of
the five states, since unknown and outdated are client-side facts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread proto/v1/v1/workspace_service.proto
Moving policy_unreadable's invariant onto this field left the never-configured
sentence in both paragraphs, making it the longest block in the message right
after RainbowDashy asked for less. Same facts in eight lines instead of twelve,
and it now says up front that the field is resolved rather than stored, which
is the question d-bytebase asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vsai12
vsai12 requested a review from d-bytebase August 27, 2026 04:31
policy_unreadable existed only on this branch, so no client can hold field 8
and reserving it protects nothing. The Capability enum's reserved 2 stays: it
is in 3.21.1, and it is what stops the cut Metadata-only slot being reused
under rows that can still carry it.

Raised by d-bytebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants