Problem/Motivation

A policy profile constrains what the agent may do through the MCP channel. It says nothing about what the governed Drupal role may do outside it — and some permissions walk straight around the profile.

Found on a site in production: the agent role held a contrib bypass permission (in this case File Gate's bypass file gate) alongside access mcp server and 18 profile permissions. File Gate declines its veto for any account holding that permission, so the role could fetch gated private files directly at /system/files/… — no MCP request, therefore no policy, no redaction and no audit entry. The governance dashboard went on reporting the profile's deny lists and redactions the whole time.

Nothing in the module could see it. It was fixed by hand, but nothing stops it recurring, and a client install would never have caught it.

Proposed resolution

Let a profile declare permissions a governed role must not hold, and enforce it:

forbidden_role_permissions:
  - 'bypass file gate'
  - 'bypass node access'
  - 'administer users'
  - 'administer permissions'
  - 'masquerade as any user'
  - 'administer site configuration'
acknowledged_role_permissions: {  }
  • Ship the list populated, so an operator inherits the protection without authoring it, and keep it per-profile configuration so a site can add the escape hatches its own contrib modules define.
  • acknowledged_role_permissions takes role_id:permission entries and records a deliberate grant in exported configuration, where a reviewer sees it — rather than the alternative, which is deleting the rule that caught it and losing the fact that anyone decided anything. It suppresses exactly the pair it names.

Two details decide whether the check is worth anything:

  • Effective permissions, not listed ones. Every governed account is authenticated, so a bypass granted to the authenticated role is held by every governed role while appearing in none of their permission arrays. A check that reads only the governed role reports the site clean while it is wide open, so the two cases are resolved separately and reported differently — the fix differs.
  • is_admin roles hold every permission implicitly, including ones a module installed tomorrow will define, so their empty permission array proves nothing. Report them as their own violation class and refuse to govern one at the point of configuration. A role already configured should still be governed at runtime: dropping it would leave the agent's traffic entirely ungoverned, which is worse than governing it imperfectly and saying so.

Remaining tasks

Surfacing, so that a finding is not merely recorded somewhere nobody reads:

  • Critical condition on the governance dashboard, counted in the posture rollup.
  • ERROR on the status report, listing each violation with its remedy.
  • Non-zero exit from a new drush mcp-sentinel:role-audit — a deploy gate, to run after config:import.
  • An audit-chain entry the moment a role save introduces one. This records rather than blocks: refusing the save would break config import and put the module in the way of an operator changing their own site's permissions.

API changes

Two new policy-profile properties, forbidden_role_permissions and acknowledged_role_permissions, with getters on McpPolicyProfileInterface; a new service mcp_sentinel.role_assertions; a new Drush command mcp-sentinel:role-audit. An update hook writes the shipped list onto existing profiles so the assertion is visible in config export rather than implied by a default.

Implementation: mcp_sentinel PR 68.

Comments

jmcerda created an issue. See original summary.

jmcerda’s picture

Status: Active » Fixed

Fixed in 2.0.0.

Policy profiles now assert their governed roles' **effective** permissions, inherited `authenticated` grants included, via `McpRoleAssertions`. Two new profile properties: `forbidden_role_permissions` (shipped with a default list) and `acknowledged_role_permissions` for deliberate exceptions, so an accepted grant becomes a line in exported configuration rather than an invisible hole.

The acknowledgement format is `role_id:permission`, not a bare permission string. Worth stating explicitly because a bare string passes config schema validation and then matches nothing — a silent no-op that looks like a recorded decision. That mistake was made on a real site during the upgrade and only caught in review.

Two limits worth recording for anyone relying on this:

1. The check is **advisory**. `mcp_sentinel_requirements()` returns early unless `$phase === 'runtime'`, so it surfaces on the status report, on role save, and through `drush mcp-sentinel:role-audit` — it never blocks a request or a database update. That is the right call (refusing the save would break config import) but it means the finding has to be read by someone.
2. An `is_admin` governed role produces a violation that **cannot be acknowledged**: `McpRoleAssertions::evaluate()` returns on `is_admin` before it reads the acknowledgement list. That is correct — an admin role holds every permission by definition, so no per-permission exception is meaningful — but it means such a role reports a permanent ERROR row.

On (2), a caution for other maintainers: the tempting fix is to delete the profile so the role stops being governed and the row clears. Don't. Removing the profile drops the role to the role-less `default` profile via `resolveForRoles()`, which silently discards whatever `allowed_ips`, config gating and caps the specific profile carried. A permanent accurate warning is better than a quietly removed boundary.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

jmcerda’s picture

Status: Fixed » Closed (fixed)

Shipped in 1.13.0. Closing after release.