fix(mcp): withhold Allow unless the consent page holds a served ceiling - #21254
Open
vsai12 wants to merge 9 commits into
Open
fix(mcp): withhold Allow unless the consent page holds a served ceiling#21254vsai12 wants to merge 9 commits into
vsai12 wants to merge 9 commits into
Conversation
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
requested review from
a team,
RainbowDashy,
d-bytebase and
ecmadao
as code owners
August 26, 2026 20:03
|
The latest Buf updates on your PR. Results from workflow Proto linter / lint-protos (pull_request).
|
There was a problem hiding this comment.
💡 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".
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
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
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>
ecmadao
approved these changes
Aug 27, 2026
d-bytebase
reviewed
Aug 27, 2026
… 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>
d-bytebase
reviewed
Aug 27, 2026
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>
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>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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 isREAD_ONLY,with only the
GetMCPInforequest blocked: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:
GetMCPInforefused whole onMCPCeilingUnreadable/MCPCeilingUnserved.Why refusing was wrong
The mode contents do not come from the stored row.
modes,methodsandenginesare 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.
(
capabilityandignore_masking_exemptionsdo come from the row — that iswhat 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_ONLYMCPInfo.policy_unreadablecarries the one state the number cannot, followingBOT-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_exemptionssets it over a perfectly readable capability. It istherefore wider than
MCPSetting.capability_unreadableand not interchangeablewith it — see "One field, two row shapes" below. A ceiling that parsed but no
mode serves needs no field: the number survives on
capabilityandmodeshasno row for it.
A failed read still refuses, with
CodeUnavailable. It is the one verdict withno 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.
readConsentCeilingreads the response into five states, andAllow 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.
modeunknownunreadableunservedoutdatedThe generic card is deleted rather than demoted.
refuseConsentByCeiling(
backend/api/oauth2/consent_audit.go:63) runs on every allow POST, so nonon-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.
TestGetMCPInfoHandler/a ceiling this build cannot read is described, not refused.../a ceiling no mode serves is described by the value nobody serves.../a workspace that never configured MCPOAuth2ConsentPage.test.tsx— a failed policy read offers no grant and can be retriedMCPAccessPolicySection.test.tsx— the mode-contents drawer is available while repairing an unreadable ceilingLive check on the running dev server,
Allowcounted by accessible name:GetMCPInfoREAD_ONLYREAD_SIDEWAYSREAD_ONLYREAD_ONLYorigin/mainpageClicking 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 ./...→ 0issues ·
go build ./...·go test -p=8 -timeout 30m ./backend/...→ 107packages green ·
pnpm --dir frontend fix/check/type-check/test→3832 passed.
Three later local runs each hit one flake in
backend/tests— a differenttest every time (
TestTransactionMode,TestTenantBackfill,TestSQLQueryStopOnError), every one failing withport "<port>/tcp" not foundfrom testcontainers, and the two I re-ran alone both passed. Local Dockerhad accumulated 66 containers against an 8.2 GB limit. The only non-comment
production change on this branch is inside
GetMCPInfo's ceiling switch, whichnone 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.shreports 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
MCPModeContentsSheetandMCPAccessPolicySection). Netgrowth is +46.
The script itself had a hole and this PR's first measurement was wrong because
of it: its pathspec excluded
*.test.tsbut not*.test.tsx, so test commentscounted 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,""), whichan admin repairs in the settings UI, and a value of the wrong JSON type
(
{},true,1.5,[]), which fails the whole unmarshal and needs anoperator to correct the stored value.
GetSettingrefuses the second kindoutright, 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_unreadablemeans"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 forMCPCeilingUnreadable, so both arealready audited as policy denials. The only surface that distinguishes them is
the settings page, and it learns that from
GetSettingfailing, not from here.One bit of consequence, so one bool.
Three changes rather than a new representation:
unreadableline is now operator-neutral — "until this workspace's MCPpolicy 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.
policy_unreadableand its doc no longer claims it isthe "same state, same representation" as
MCPSetting.capability_unreadable.Codex found the old name asserts something false: given
{"capability":"READ_ONLY","ignoreMaskingExemptions":[]}the capability isreadable 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 throughGetMCPInfo. That branch had no coverage, which is why it was missed. Mutation-checked: making the storestop 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
MCPCeilingUnavailablewould have been worse (it says "Retry shortly" for apermanent condition and drops the denial audit row).
Known limitation
Dismissing an undisclosed policy posts
action=deny, andauthorize.go:121answers that before the ceiling is ever classified — so the waiting client gets
access_deniedwith the description "user denied the request". For theunknownstate nobody denied anything, and the same condition reached throughthe POST returns
temporarily_unavailable. RFC 6749 admitsaccess_deniedforan 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
refuseConsentwrote an
MCPConsentApprovedenial.MCPConsentDisabledalready carries thisgap for the disabled screen; this extends it to two more states. Same fix, same
place.
Not in this PR
The
MCPSettingetag. A provenance check found the design-doc §17 requirementhad 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
isDevchange, no seed (BOT-108), no SDK work (BYT-10061), no gate/clamp/masking
changes.
Closes BOT-106.