Skip to content

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.

@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.

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::"<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.

The action is the call’s risk class, which the server derives and a client cannot supply: Action::"read", Action::"write", or Action::"destructive".

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.

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.

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:

  1. If any plain forbid applies, the call is denied.
  2. Otherwise, if no permit applies, the call is denied by default. This holds even when a hold applies: an approval lifts a hold and grants nothing by itself.
  3. Otherwise, if a hold applies, the call requires approval.
  4. 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.

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 forbid on any of them denies the call;
  • otherwise a missing permit on 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.

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 … writes policy.cedar.sig under 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 no v<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 .cedar file 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.
Terminal window
mcp-devtools policy check policy.cedar [--public-key <b64>] [--json]
mcp-devtools policy sign policy.cedar --key policy-key.pk8
mcp-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.pk8

check 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.

A Cedar expression is never presented as a YAML rule.

  • GET /admin/policy and POST /admin/policy/validate report language: "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/diff returns language: "cedar", empty *_rules, and current_policies / candidate_policies, each entry holding the policy’s id, effect, and exact source text.

  • A refused Cedar candidate is 400 invalid_request with a diagnostics array beside error and error_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, \n or \r line endings; the end is exclusive. A convention violation (@id, @effect, a permit for every action, a duplicate id) has no span: 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 no diagnostics; its response is unchanged.

  • GET /admin/policy/schema returns the vocabulary of the schema above: entity_types and common_types (each name, attributes with name, type, optional, an enumerated type’s values, and member_of), and actions (name, principal_types, resource_types, context). It is a read; nothing is journaled. A build without the cedar feature answers 503 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. and context. 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 in when and unless are not evaluated. Each row therefore carries cedar_principal_scope (the scope as Cedar text), cedar_conditions: not_evaluated, and scope_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”. Use policy explain for what a policy decides for a particular call.

  • 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 when clause is not discovered by the access review; supply subjects through the group or scope snapshot.

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.