Problem/Motivation
denied_entity_types is the control an operator is shown when asking "prove exactly what the agent may touch". It is enforced above the entity API — and raw SQL runs underneath it.
The companion Node.js connector exposed a drupal_drush_sql_query tool that ran drush sql:query over SSH. That path never enters Drupal's entity API, so no policy profile, no denied_entity_types, no redacted_fields, no DLP and no audit entry applied to anything it read.
Steps to reproduce
With profile on every shipped deny list, these still return the data:
SELECT entity_id, field_nda_date_value FROM profile__field_nda_date; SELECT uri, filename FROM file_managed WHERE uri LIKE 'private://%';
Adding entity types to the deny list does not close it: the boundary is in the wrong place, not too narrow.
A Drush hook cannot close it either, which is worth stating because it looks like the obvious fix. sql:query declares Bootstrap(level: MAX, max_level: CONFIGURATION), and Drupal module command files are only discovered inside bootstrapDrupalFull(). The module system is therefore never loaded on that command's path, so no hook, subscriber or policy check in any Drupal module can fire for it. The same holds for sql:cli, sql:dump and php:eval.
Proposed resolution
Move the execution point into Drupal, and gate it on a capability that ships off.
- New policy-profile flag
allow_raw_sql, FALSE by default and after upgrade, so enabling raw SQL is an exported, reviewable decision rather than something a profile inherits. - New command
drush mcp-sentinel:sql-query. A module-provided command is only reachable after a full bootstrap, so the profile, a statement guard and the audit chain all apply. - The guard resolves
denied_entity_typesandredacted_fieldsdown to physical tables and columns through the entity table mapping — which is what connects a field table such asprofile__field_nda_dateback to its denied entity type. Only entity tables are readable; every other table is refused, including core's ownconfigtable, which carries Key provider values and is not an entity table. - A redacted field's column is refused anywhere in the statement rather than masked on output. A predicate over one is an oracle that leaks it a character at a time, and
SUBSTR()defeats masking outright. - Every invocation, permitted or refused, is written to the tamper-evident audit chain with its statement text. The command refuses to run at all when audit logging is off.
Remaining tasks
What it costs, stated plainly: the governed path is deliberately much narrower than sql:query — a single SELECT, entity tables only, no expressions beyond COUNT(), and no SELECT * on a table carrying a redacted column. Existing operator queries will need rewriting, or belong on the operator's own shell.
The guard is deliberately not a SQL parser. A parser would invite the belief that raw SQL is fully governed, and it is not — an expression over an allowed column can still say more than an entity read would. That residual risk is why the capability ships off rather than merely guarded.
API changes
New policy-profile property allow_raw_sql (default FALSE) with a getter on McpPolicyProfileInterface; new service mcp_sentinel.raw_sql_guard; new Drush command mcp-sentinel:sql-query. An update hook backfills the flag at FALSE on existing profiles, so no site gains the capability by upgrading.
The trust model in README.md now also states plainly that shell access is outside Drupal governance by construction, which it had previously been silent about.
Implementation: mcp_sentinel PR 67 and drupal-mcp-connector PR 133 — without the connector change the bypass remains reachable.
Comments
Comment #2
jmcerdaFixed in 2.0.0.
Raw SQL no longer reaches the database as a drush subprocess. It now executes inside Drupal behind a fail-closed check and is refused outright unless the resolved policy profile opts in via the new `allow_raw_sql` flag, which ships FALSE. The connector's `drupal_drush_sql_query` path is replaced by `drush mcp-sentinel:sql-query`, which resolves a profile through the same weighting and tie-breaking as an HTTP request (`McpPolicyResolver::resolveForRoles()`) rather than reimplementing it and drifting.
Because the statement now runs through the module, `denied_entity_types`, `redacted_fields` and the audit chain apply to it — which was the whole gap here. A SELECT against a field table previously returned data for entity types listed on every deny list.
Released and running in production on a governed site: `allow_raw_sql: false` on all five policy profiles there, and `drush mcp-sentinel:role-audit` reports clean apart from a known unacknowledgeable `is_admin` finding.
Note for anyone upgrading: 2.0.0 cannot be deployed in a single step if your pipeline runs database updates before config import — see #3613962.
Comment #4
jmcerdaShipped in 1.13.0. Closing after release.