Native Cedar policies
A policy file whose name ends in .cedar is a Cedar
policy set that the server evaluates as written. No YAML rule stands behind it
and nothing is translated. This is separate from the older Cedar path, where
MCP_POLICY_ENGINE=cedar translates a YAML policy into Cedar; that path is
unchanged and still used for every YAML file.
| Policy file | MCP_POLICY_ENGINE=file (default) |
MCP_POLICY_ENGINE=cedar |
|---|---|---|
policy.yaml (any name not ending .cedar) |
YAML rule walk | YAML translated to Cedar, then evaluated |
policy.cedar |
refuses to start | evaluated natively |
The file name decides the language. The server never inspects the bytes to
guess: YAML text at a .cedar path is a Cedar parse error, and Cedar text at
a .yaml path is a YAML parse error. A policy file keeps its language for as
long as the server runs, so a reload or an admin candidate is always read the
same way as the document it replaces. Changing language means changing
MCP_POLICY_FILE and restarting.
Native Cedar needs a binary built with --features cedar. Without it, a
.cedar file is refused at startup and by every policy subcommand.
Example
Section titled “Example”@id("sre-read-qa-logs")permit ( principal in Group::"SRE", action == Action::"read", resource)when { context.vendor == "grafana" && context.environment == Environment::"qa" && resource.resource_type == ResourceType::"datasource" && resource has id && resource.id like "loki-*"};
@id("no-finance-data")forbid (principal, action, resource)when { resource has id && resource.id == "loki-finance" };
@id("hold-production-writes")@effect("require_approval")forbid (principal, action == Action::"write", resource)when { context.environment == Environment::"prod" };The demo’s hand-authored policies are in deploy/demo/*.cedar.
Schema
Section titled “Schema”The schema is crates/policy/src/policy/cedar/native.cedarschema, compiled
into the binary. Every document is validated against it in Cedar’s strict
mode before it can be put in force. Additions to the schema are backwards
compatible. Renaming or removing anything in it breaks deployed policies and
is a reviewed migration of its own.
Principal
Section titled “Principal”Principal::"<tenant>/<subject>", built from validated token claims.
| Attribute | Type | Meaning |
|---|---|---|
tenant |
String | Tenant label. |
subject |
String | The token’s sub. |
authority |
String | "local", or the OIDC issuer URL that validated the token. |
groups |
Set<String> | Group claims, exact values. |
scopes |
Set<String> | Token scopes. |
kind |
SubjectKind, optional | SubjectKind::"human" or "machine", only when the validator verified it. |
Each group is also a parent entity, so principal in Group::"SRE" and
principal.groups.contains("SRE") mean the same thing.
Action
Section titled “Action”The action is the call’s risk class, which the server derives and a client
cannot supply: Action::"read", Action::"write", or Action::"destructive".
Resource
Section titled “Resource”One resource the call addresses.
| Attribute | Type | Meaning |
|---|---|---|
resource_type |
ResourceType | vendor_resource, issue, project, channel, datasource, dashboard. |
scope |
ResourceScope | ids (the call provably addresses exactly these ids), collection (a list endpoint), or unscoped (the server could not prove what the call reaches). |
id |
String, optional | Present only when scope is ids. |
For an id-scoped call the entity is Resource::"<resource_type>/<id>". Match
collection and unscoped calls on resource.scope, not on the entity id.
Context
Section titled “Context”| Attribute | Type | Meaning |
|---|---|---|
vendor |
String | Canonical vendor name, lower-case. |
environment |
Environment | prod, staging, qa, dev, unclassified. From configuration, not the request. |
tool_name |
String | The MCP tool. |
normalized_action |
NormalizedAction | For example query_logs, read_issue, create_issue, passthrough; the browser tools are open_page, read_page, act_on_page and close_page on a page resource whose id is the opened host. |
upstream_authority |
UpstreamAuthority | shared or delegated. |
method |
Method | GET, POST, PUT, PATCH, DELETE. |
canonical_path |
String | Canonical request path. / when a native tool’s intent is authorized before any request. |
operation_class |
OperationClass, optional | http, database or browser (the playwright_* tools). |
destination_origin |
String, optional | Present only while an outbound request is being authorized. |
constraint |
{resource_type, id}, optional |
A resource of another type that bounds the call, such as the project of a Jira search. |
resource_labels |
Set<String>, optional | Trusted upstream labels, lower-cased. Present only while a fetched resource is being authorized for disclosure. |
Environment, NormalizedAction, ResourceType, ResourceScope,
UpstreamAuthority, Method, OperationClass, and SubjectKind are
enumerated entity types. Environment::"pord" is therefore a validation
error, not a policy that never matches. An optional attribute must be guarded
with has; strict validation refuses a policy that reads one unguarded.
The reported client name and version are deliberately absent. They are self-reported telemetry and never an authorization input.
Conventions on top of Cedar
Section titled “Conventions on top of Cedar”Cedar itself provides permit, forbid, default denial, and forbid
precedence. The server adds these rules, all checked when a document loads.
Every policy has a unique @id("…"). The id is the rule_id in decisions,
audit records, and policy explain. A policy without one, or a duplicate, is
refused.
A permit must name its actions. permit (principal, action, resource)
is refused, because it would grant writes as well as reads. Write
action == Action::"read" or action in [Action::"read", Action::"write"].
This carries over the YAML rule that a grant is a read grant unless it names a
risk. A forbid may leave the action open.
Approval is @effect("require_approval") on a forbid. Cedar has no third
effect. A hold is written as a prohibition that an approval lifts, so a stock
Cedar authorizer reading the same file denies a held call and never grants it.
The annotation on a permit is refused, as is any other @effect value. The
decision is made in this order:
- If any plain
forbidapplies, the call is denied. - Otherwise, if no
permitapplies, the call is denied by default. This holds even when a hold applies: an approval lifts a hold and grants nothing by itself. - Otherwise, if a hold applies, the call requires approval.
- Otherwise the call is allowed.
After a second person approves, the requester re-issues the call and it is
evaluated again against the policy then in force, exactly as with a YAML
require_approval rule (ADR). Note the
difference from YAML: a YAML require_approval rule holds a call by itself,
while a native hold also needs a permit for the same call.
Templates are not supported. Write static policies.
Calls that address several resources
Section titled “Calls that address several resources”A call can address several resources (a query over two datasources) or be bounded by several constraint ids (a Jira search over two projects). The server evaluates one Cedar request per resource and per constraint id, and combines them:
- a plain
forbidon any of them denies the call; - otherwise a missing
permiton any of them denies the call; - otherwise a hold on any of them holds the call;
- otherwise the call is allowed.
So a policy always reads one resource and one context.constraint, and
cannot allow a call because one of its resources is allowed. A call that
needs more than 256 evaluations is denied.
What stays the same as YAML
Section titled “What stays the same as YAML”A native document is the same signed bundle document as a YAML policy, so the lifecycle is shared, not duplicated:
- Admission checks run first. A call whose resource could not be classified is denied before any policy is consulted, as are calls denied by the installed tool inventory.
- Detached signatures.
policy sign policy.cedar --key …writespolicy.cedar.sigunder the same domain and key as a YAML policy. - Version label.
cedar+sha256:<16 hex>of the exact bytes, in every decision and journal record. Cedar has no document version number, so there is nov<n>prefix. - Reloads. A changed file is parsed, checked, and validated before it replaces the policy in force. A document that fails keeps the last good policy in force, reports degraded health, and is journaled as rejected.
- Authorization generations. The manifest binds the
.cedarfile by its SHA-256 like any other policy; publication, monotonic generation numbers, and two-person approval are unchanged. - Decision cache. Entries are bound to the snapshot generation, so a reload or publication is in effect on the next call.
- Fail closed. Cedar skips a policy whose evaluation errors. The server
instead denies the call with
policy evaluation error, reports the snapshot degraded, and stops reusing cached decisions until a new policy loads.
mcp-devtools policy check policy.cedar [--public-key <b64>] [--json]mcp-devtools policy sign policy.cedar --key policy-key.pk8mcp-devtools policy explain policy.cedar --tool grafana_query_logs \ --arguments '{"datasourceUid":"loki-qa","query":"{}"}' --group SRE [--json]mcp-devtools generation seal --manifest generation.yaml --policy policy.cedar --key policy-key.pk8check and sign refuse a document the gateway would refuse. check --json
adds "language": "cedar" (absent for YAML). explain lists every policy with whether it
applied; a permit counts as matched only when it applies to every resource
the call addresses, a forbid when it applies to any. Its JSON output carries
"language": "cedar" and "cedar": {"native": true, "effect": …, "rule_id": …}.
There is no agrees_with_file_engine field, because there is no YAML rule to
compare with; --engine has no effect on a native document.
Admin API, console, and access reviews
Section titled “Admin API, console, and access reviews”A Cedar expression is never presented as a YAML rule.
-
GET /admin/policyandPOST /admin/policy/validatereportlanguage: "cedar". The field is absent for a YAML policy, whose responses are unchanged. A candidate is read in the language of the policy in force, so a YAML candidate cannot replace a Cedar policy or the reverse. -
POST /admin/policy/diffreturnslanguage: "cedar", empty*_rules, andcurrent_policies/candidate_policies, each entry holding the policy’s id, effect, and exact source text. -
A refused Cedar candidate is
400 invalid_requestwith adiagnosticsarray besideerroranderror_description(validate and diff alike):{"error": "invalid_request", "error_description": "invalid_request","diagnostics": [{"message": "for policy `typo`, attribute `vendr` in context for Action::\"read\" not found (did you mean `vendor`?)","span": {"line": 4, "column": 8, "end_line": 4, "end_column": 21}},{"message": "policy 2 has no `@id(\"…\")` annotation; …"}]}Every parse error is reported, or, for a document that parses, every convention violation and every strict-validation error and warning, up to 32, each message at most 512 characters. Lines and columns are 1-based and count Unicode characters within a line, so a position means the same with
\r\n,\nor\rline endings; the end is exclusive. A convention violation (@id,@effect, a permit for every action, a duplicate id) has nospan: Cedar gives no location for a whole statement. Diagnostics are built from the candidate’s text and the built-in schema only, never the policy file’s path, the policy in force, or request data. A YAML refusal carries nodiagnostics; its response is unchanged. -
GET /admin/policy/schemareturns the vocabulary of the schema above:entity_typesandcommon_types(eachname,attributeswithname,type,optional, an enumerated type’svalues, andmember_of), andactions(name,principal_types,resource_types,context). It is a read; nothing is journaled. A build without thecedarfeature answers503 admin_backend_unavailable. -
The console labels the active document and the editor as Cedar, and the candidate downloads as
policy-candidate.cedar. Keep that extension when signing and sealing. The editor highlights Cedar, underlines each located diagnostic, moves the caret to one when its location is selected, and completes from the schema:principal.,resource.andcontext.list attributes (nested records too,context.constraint.),Environment::"and the other enumerated types list their labels,Action::"the actions, and Ctrl+Space anywhere offers keywords and type names. -
Access reviews report, for each subject, the policies whose principal scope (
principal,principal == …,principal in Group::…) includes that subject. Conditions inwhenandunlessare not evaluated. Each row therefore carriescedar_principal_scope(the scope as Cedar text),cedar_conditions: not_evaluated, andscope_match: not_evaluated, and the CSV export gains those two columns. A row means “this policy may apply to this subject”, not “this subject has this access”. Usepolicy explainfor what a policy decides for a particular call.
Limitations
Section titled “Limitations”- No globs over groups or scopes. YAML allows
groups: ["eng-*"]; Cedar has no way to pattern-match the members of a set. Name groups exactly. - No data profiles. Database dispatch rules are a YAML schema 2 feature; database calls stay denied under a native policy.
- Access reviews are scope-level only, as described above.
- A principal named only inside a
whenclause is not discovered by the access review; supply subjects through the group or scope snapshot.
Evaluation cost
Section titled “Evaluation cost”A decision hands the Cedar interpreter only the policies that can apply to
the call. A policy is skipped only when Cedar would evaluate it to false
without an error: its action or principal scope does not match, or a leading
when condition that cannot raise is false for the call (for example
context.vendor == "grafana", ["a", "b"].contains(context.tool_name),
resource has id && resource.id like "loki-*"). A policy whose every
condition is such a test is decided the same way: once they all hold for the
call it applies, and it is recorded without an interpreter call (debug builds
still ask Cedar and check the answer). A policy with any other condition, an
unless body, or a resource scope reaches the interpreter whenever its tests
hold. Forbid precedence, holds, and fail-closed errors are unchanged. Writing
the cheap, specific tests first in when (vendor, tool, environment, resource
id), and only those where they suffice, lets more policies be skipped or
decided outright.
Measured on an Apple M1 (docs/benchmarks/2026-10-09-cedar-trivial-policies.txt):
about 5 µs per uncached decision for a 500-policy document on one resource,
21 µs on 32 resources, and 13 µs when 125 fully-guarded policies
genuinely apply (135 µs before they were decided without the interpreter).
MCP_POLICY_DECISION_CACHE saves about 4 µs per hit and costs about 1 µs per
miss for typical documents, so it pays above a ~20 % hit rate. Documents where
many policies with conditions beyond those tests apply at once still cost
about 1 µs per such policy, because Cedar evaluates every one of them to rule
out an error.