Closed (fixed)
Project:
MCP Sentinel
Version:
2.3.0
Component:
Code
Priority:
Normal
Category:
Task
Assigned:
Unassigned
Reporter:
Created:
13 Aug 2026 at 14:52 UTC
Updated:
29 Aug 2026 at 20:45 UTC
Jump to comment: Most recent
Rate, result-count, and response-size mechanisms exist, but released defaults can be unlimited. That does not establish a mass-read/exfiltration floor, and volume limits alone do not constrain which data classifications may leave the source for which destinations.
Require finite source-side budgets for governed reads and enforce classification-aware allowed egress destinations before returning data.
chained-action budgets.
secure-install verification.
chained calls.
tenant/site boundaries, and concurrent quota use.
Governed read policy gains finite budget and allowed-destination requirements with consistent denial results.
Comments
Comment #2
jmcerdaPublic working mirror: GitHub #109.
Drupal.org remains the authority for this work item; implementation discussion and pull-request linkage may occur in the mirror.
Comment #4
jmcerdaPart 1 of this issue is implemented, merged, and released in 2.7.0: read budgets are finite by default on every governed read channel.
What changed (merged in the working PR; an automated-review pass added two adopted hardenings — the context endpoint shares the profile-wide request bucket, and the GraphQL HTTP seam accepts either governed scope so write-only mutation tokens are not refused before the operation gate):
mcp_sentinel.read_budgetsresolver clamps it to the defaults inmcp_sentinel.settings:read_budget_defaults(500 results, 8 MiB, 600 requests/60 s, 120 collection pages/60 s); an explicit finite profile value always wins. Update 10019 seeds the settings; absent keys already resolve to built-in constants, so enforcement never depends on the update running first.read_budget_exceeded) and a windowed per-principal collection page budget (429,page_budget_exceeded) so pagination cannot amplify a bounded page into an unbounded export. The effective cap bindspage[limit], and an absentpage[limit]is pinned when the cap is below JSON:API's default page size.response_size_cap_exceeded) — those two channels previously had no byte measurement at all. The governed raw-SQL drush channel and the/drupal-mcp/contextdocument use the same effective budgets.read_budget_deniedaudit row (budget class, profile, path, sizes — never payloads).require_finite_read_budgets: falseis the explicit non-production override: it restores 0 = unlimited and pins a permanent status-report warning, so a secure-install verification cannot report clean while it is active.Coverage: TDD kernel tests for the resolver, clamping in the rate limiter and exfiltration guard, the request/page budgets, absent-limit pinning, the response subscriber (including payload-never-leaks and evidence-row assertions), per-principal accounting, the requirements warning, and the seeding update hook.
Remaining scope on this issue (part 2): classification-aware allowed egress destinations evaluated before data leaves the source. That needs a data-classification model pinned first; keeping the issue open at Needs review for part 1 review rather than closing partial.
Comment #5
jmcerdaPart 2 (classification / destination egress) design pass is complete on the source side; implementation waits on ratification. The proposed shape, summarized:
- Labels: a site-defined ordered classification vocabulary (shipped default public < internal < restricted), assigned by configuration in a global classification_map keyed by entity type/bundle, with optional per-field overrides. Unlabeled data takes the lowest label; the identity/credential entity types the default profile already denies ship labeled restricted.
- Destination, defined honestly for a source: the pair (server-resolved principal → profile, governed egress surface). The surface vocabulary already exists (McpGovernedSurface); governed drush SQL becomes a first-class surface case instead of an audit-metadata string. A northbound declared destination/ceiling can only narrow the effective ceiling, never widen it — safe before the connector's OAuth principal work lands, attestable after it.
- Policy: per-profile egress_ceilings mapping surface → highest label the profile may receive there. An empty map changes nothing — the mechanism ships dark; enforcement starts when a site labels data. A production-oriented profile with labels but no ceilings draws a status-report warning, mirroring the finite-budget override warning from part 1.
- Enforcement reuses the part-1 seams: entity read access, the field-level redaction machinery for over-ceiling fields, the governed-response subscriber (structured code classification_egress_denied), the raw-SQL table map, the context endpoint. Existing entity/field hard denies always win; DLP acts as a tighten-only detector. Denial evidence carries label names, never values.
- Bypass resistance: budgets and ceilings key on server-resolved identity, so pagination or chained calls cannot reset them; the connector leg binds the same context northbound behind its OAuth resource-server and entitlement-filtering work.
One scope point flagged for the record rather than adjusted silently: the acceptance criteria in the issue summary describe destination policy without a deployment-shape split. The source-side slice delivers classification ceilings plus narrow-only declared context; attested downstream destinations require the hosted OAuth resource-server model and arrive with that work. A precise wording adjustment to the criteria will follow ratification.
Falsifiable acceptance criteria and a slice plan are drafted (both-directions tests, including a no-behavior-change-when-unlabeled regression). Part 1 remains released in 2.7.0; this issue stays open for part 2.
Comment #6
jmcerdaT1 of the part-2 design ratified: the acceptance criteria now carry the horizon split explicitly. The OSS slice = configuration-declared labels + per-profile egress ceilings per governed surface + narrow-only declared destination context (a declaration can lower the effective ceiling, never raise it); attested downstream destinations are the hosted residual, arriving with the connector OAuth resource-server work. The remaining design decisions (destination definition, site-defined ordered vocabulary, global assignment with per-profile ceilings, redact-or-refuse by addressability, webhook/SIEM egress deferred to its own item, drush as a first-class governed surface) were ratified as proposed in comment #5; implementation of the part-2 slices follows.
Comment #7
jmcerdaPart 2 — classification labels and per-surface egress ceilings — is implemented and in review: https://github.com/Wilkes-Liberty/mcp_sentinel/pull/134 (GitHub working mirror #109). This is the OSS-honest slice described in comment #6, not the attested-destination model, which stays with the hosted OAuth resource-server work.
Against the criteria in the summary:
Classification is configuration, not content inspection. Settings gain an ordered, site-defined vocabulary (classification_labels, shipped public < internal < restricted), a global classification_map assigning labels by entity type, optionally bundle, optionally field, and a label for the schema document. Unlabelled data carries the lowest label. The shipped map labels the identity and credential entity types the default profile already denies as restricted, so P0.4's default-deny classes are expressed as labels.
A destination, source-side, is the pair (server-resolved policy profile, governed egress surface). Governed drush SQL becomes a first-class surface case beside tool, context, jsonapi and graphql, which closes the old string-literal asymmetry. Policy is a per-profile egress_ceilings map: the highest label that profile may receive on each surface. An absent surface key is no ceiling.
Enforcement reuses the part-1 seams and only ever denies more, with every hard entity-type deny and redacted field still evaluated first: entity read access; JSON:API filter access, refused type-wide when any row of that entity type exceeds the ceiling, because filter access is per entity type and a relationship-path filter is otherwise a value oracle on a restricted bundle or field; field read access and the GraphQL results alter, where an over-ceiling field takes the same redaction placeholder as a redacted one; the JSON:API request seam, which refuses an over-ceiling routed resource type for every method, since a write echoes the entity; the governed-response seam as defence in depth; the raw-SQL guard, refusing tables of over-ceiling entity types and over-ceiling columns; and the context endpoint and site-context tool, refused below the schema label with over-ceiling bundles omitted.
Every refusal carries the stable code classification_egress_denied and returns a JSON:API error document over HTTP. Every denial writes one bounded audit row per subject per request: surface, profile, entity type, bundle, field, the data label and the ceiling, any caller declarations, and the site and environment origin. Never a value. The dashboard's denial rollup now counts every refusal operation the module writes; it previously counted two of them, and its per-agent query built a fixed pair of placeholders that silently under-counted as the list grew.
Northbound context is narrow-only. A governed request may send X-MCP-Declared-Ceiling, which can lower the effective ceiling and never raise it, and X-MCP-Declared-Destination, which is recorded in evidence only. Malformed or unknown declarations narrow to the lowest label. That pair is the wire contract the connector leg binds later; it is safe before principal authentication lands precisely because it cannot widen anything.
The mechanism ships dark. An empty map, or a map with no ceilings anywhere, changes no read decision — proven by a regression test that runs a governed read through every seam under both an empty map and the update-hook-seeded state and asserts zero denials, with a control case showing the same probes refuse once a label and a ceiling are set. Update 10021 seeds the settings keys and backfills an empty egress_ceilings on every existing profile so exported configuration round-trips without drift; it never overwrites an operator value. A status-report warning names role-bound profiles that carry no ceilings while data is labelled above the floor, so the gap is visible rather than silent.
Both forms carry the new configuration: a Classification section on the module settings form, including a row editor for the map, and an Egress ceilings tab with one select per surface on the policy-profile form.
Each of the ten acceptance criteria drafted for this slice has a test proving it in both directions, including the no-behaviour-change regression. Local gate before the pull request: PHPCS with the module ruleset at exit 0, PHPStan level 6 clean, cspell clean against the drupalcode dictionary set, 598 unit and kernel tests green plus the GraphQL, server and approval submodule kernels. CI is green on every leg, including the Drupal 10.6 floor.
Two residuals recorded rather than claimed as solved. GraphQL response bodies carry no resource typing, so the response seam re-types JSON:API bodies only; GraphQL is enforced at entity and field access instead. And JSON:API meta.omitted, along with relationship routes, still reveals that an over-ceiling entity exists — its type and UUID — though never its content; client-facing reasons therefore carry only the bare code, never label prose.
Remaining for this issue: DLP as a tighten-only detector, together with DLP coverage beyond GraphQL and audit diffs, is a separate slice, and the connector leg is tracked in the connector queue.
Comment #24
jmcerdaBoth halves of this issue are now released.
Part 1, finite source-side read budgets, shipped in 2.7.0. Part 2, classification labels and per-surface egress ceilings, shipped today in 2.9.0: https://www.drupal.org/project/mcp_sentinel/releases/2.9.0
Against the acceptance criteria in the summary:
Production-oriented governed profiles require finite request, row, byte, page and chained-action budgets, and unlimited values are rejected unless the explicit non-production override is set — which raises a permanent status-report warning, so a secure-install verification can never report clean while it is active. Budgets apply at the source for every read, search, list and export path including pagination and chained calls.
Data classification and allowed destination policy are evaluated before egress. Labels are configuration: an ordered site vocabulary, assigned by entity type, bundle or field. A destination is the pair (server-resolved policy profile, governed surface), with the governed drush SQL command promoted to a first-class surface. Per-profile egress ceilings are enforced at entity read access, JSON:API filter access, field access and the GraphQL results alter, the JSON:API request and response seams, the raw-SQL guard, and the context endpoint and site-context tool. Hard entity-type denies and redacted fields are evaluated first and always win.
Denials use stable reason codes and produce bounded, non-sensitive evidence: every refusal carries classification_egress_denied, and each writes one audit row per subject per request naming surface, profile, entity type, bundle, field, the data label and the ceiling, plus the site and environment origin — never a value.
Tests cover pagination amplification, multiple read tools, chained requests, destination denial, and concurrent quota use, each proven in both directions, including an explicit regression test that an unlabelled site with no ceilings sees zero new denials on any seam.
Two pieces of the design were deliberately not folded in here, so they are recorded rather than dropped:
The DLP work — DLP as a classification-aware, tighten-only detector, plus its coverage gap beyond GraphQL and audit diffs — is now issue #3617061. The structural obstacle on the JSON:API and REST paths (field access can only deny, and the normalizer stack has no stable per-value alter hook) is named there and needs settling on its own terms rather than being hand-waved here.
The connector leg, binding the same controls northbound to the authenticated principal and grant, lives in the connector queue and is behind its OAuth resource-server and entitlement work. The wire contract it will implement shipped here: a governed request may send X-MCP-Declared-Ceiling, which can only narrow the effective ceiling and never widen it, and X-MCP-Declared-Destination, which is recorded in evidence only.
Named residuals, so the horizon split in the summary stays honest: GraphQL response bodies carry no resource typing, so the response seam re-types JSON:API bodies only and GraphQL is enforced at entity and field access instead. JSON:API meta.omitted and relationship routes still reveal that an over-ceiling entity exists — its type and UUID — though never its content, and client-facing reasons carry only the bare code.
Marking fixed.