Policies
Author enforcement policies in Cedar: from plain language to deployed
Overview
A Checkpoint policy is a Cedar policy that decides what happens when the Gateway or Middleware classifies a request. You author policies in Compose: describe the rule in plain language, Checkpoint compiles it to Cedar, you dry-run it against real traffic, then Authorize & deploy.
Migrating from the legacy policy config? Earlier versions of Checkpoint used a structured JSON
config (default_action, block_threshold, allow_list / deny_list, path_rules). Path rules
are no longer legacy-only: Authorize & deploy compiles every enabled path rule into the
deployed Cedar bundle alongside your authored rules, so rules you save in the structured editor
are enforced by the engine the next time you deploy. allow_list / deny_list, default_action,
and reputation thresholds are still bespoke-only. See Common patterns for the
Cedar equivalent. A project with no deployed Cedar policy falls back to a permissive baseline
until you deploy one.
How policies evaluate
Checkpoint uses an allow-by-default, carve-out posture. Every deployed policy starts from a baseline that permits all traffic:
@id("baseline-allow")
@verdict("ALLOW")
permit ( principal, action, resource );Each rule you add is a forbid that carves out a stricter verdict (BLOCK, CHALLENGE, REDIRECT, or INSTRUCT) for the requests it matches. Cedar is forbid-overrides: when more than one rule applies to a request, the restriction wins. So a policy reads as "allow everything, except…". You never enumerate the traffic you want to let through.
A rule matches on facts the detection engine supplies about each request:
principal.category values
principal.category is a projection of the detection class, not the class itself. The engine emits exactly five values:
bot is coarse, and headless browsers are inside it. Browser-automation traffic classifies as
AgentClass::HeadlessBrowser internally, but that projects to DetectionClassDetail::Bot, so it
reaches Cedar as principal.category == "bot", the same value as Googlebot and curl. There is
no distinct automation category to match on today. To single out headless traffic, gate on
principal.name instead (see the example below).
automation and unknown are not live values for principal.category. The shared TypeScript
schema does declare Automation and Unknown, so you will see both in SDK source, but the engine
can never emit them. principal.category is built only from the engine's verification result, and
the Rust detection-class enum has just four variants: Human, AiAgent, Bot, IncompleteData.
A rule matching either value is therefore unreachable on the Gateway and the TS SDKs, however
legal Automation / Unknown may be elsewhere in the type system. (unknown is additionally
reachable on .NET, whose DetectionClassType carries an extra member.) Treat both as
reserved: do not use them in rules.
principal.name is sometimes a group label
Named vendors come through as themselves: GPTBot, ClaudeBot, ChatGPT, Googlebot. But the non-AI patterns match to a shared label, so a whole family of tools collapses onto one name:
principal.name == "Puppeteer" will never match: the engine reports that traffic as
Automation Tools. Match the label, not the product name.
principal.reputation is not populated yet
Do not write reputation-gated rules today: they match every request. No shipped enforcement
surface supplies principal.reputation: the Gateway, the TS SDK middlewares, and .NET all
deliberately omit it (each has a detection-confidence score, not an agent-reputation score).
Meanwhile the engine keeps principal.reputation always present, defaulting it to 0.0 so an
attribute gate never errors. The two facts combine badly: a rule like
principal.reputation.lessThan(decimal("0.7")) sees 0.0 on every request (humans included)
and therefore matches all traffic. Threading a real reputation source onto the enforcement
surfaces is a roadmap item; until it lands, gate on principal.category, principal.name, or
principal.delegation instead.
On scales, for when reputation does land: registry and Bouncer surfaces score agent reputation 0–100 (thresholds like 60), while the engine consumes a normalized 0.0–1.0 score for the principal.reputation fact. Both are correct at their layer: write Cedar thresholds as decimals (decimal("0.7")), and leave translating between the scales to the reputation service rather than comparing numbers across layers.
principal.confidence has no default: absent means absent
Unlike principal.reputation (always present, defaulting to 0.0), principal.confidence is populated only when the host supplies it: the Gateway, the Express/Next.js SDK middlewares, and .NET all forward the same detection-confidence score (0–100) they already compute, so a rule gating on it is live today across every enforcement surface. The absence is real, though: a rule referencing principal.confidence against a request whose host never supplied one is a Cedar evaluation error, which fails closed to Block rather than silently matching or erroring open.
context.ip_routing_type / context.is_proxy are preview-only today
No live enforcement surface supplies network/IP-intelligence facts yet. The Gateway, the SDK
middlewares, and .NET do not resolve a request's true IP against a routing-type or proxy/VPN feed,
so a deployed rule gating on context.ip_routing_type or context.is_proxy will hit the same
absent-attribute evaluation error as an unpopulated principal.confidence: it fails closed to
Block for every request, not "never matches." The Test dry-run preview lets you supply these
facts manually per sample so you can shape and review a rule before the network data source lands;
treat a deployed rule on either fact as blocking all live traffic until then.
Use when { … } for the facts that make a rule apply and unless { … } for carve-outs (for example, exempting an agent that presents a verified delegation).
Authoring in Compose
Compose (Policy → Compose, /policy/compose) turns a plain-language description into a reviewable Cedar policy.
You describe the rule in plain language (⌘⏎ compiles), review what it emitted, dry-run it, then deploy. Compose renders the generated rules as editable sentence chips alongside the raw Cedar: what you see is what runs, and you can hand-edit the Cedar directly. Run dry test replays the policy against representative agents, or your last 7 days of traffic, through the real engine (dry run · no traffic affected) and reports the verdict and reason per request. Authorize & deploy promotes it to production, enforcing on the gateway within about a minute.
For the click-by-click walkthrough, see the Write and deploy a policy cookbook.
Draft vs. deployed
Save as draft (and edits made on a policy's detail page) update the draft only: the gateway keeps enforcing the last deployed version until you redeploy. A live policy shows an ● Enforcing on the gateway badge; a policy with saved-but-not-redeployed changes shows ● Enforcing a previous version: redeploy to apply. Deploying is what flips a project into engine enforcement.
Saving a path rule (in the structured Enforce editor or an agent's Permissions tab) works the same way: it changes nothing until the next Authorize & deploy. If your project saved path rules before this behavior shipped, they show the same "enforcing a previous version" state until you redeploy: that one redeploy compiles them in. A rule the engine can't express (regex-only agent targeting, a REDIRECT with no destination, a path pattern outside the supported glob grammar) is listed at deploy time and is not enforced by the engine until you fix it or explicitly acknowledge deploying without it.
Managing a policy
The policy list lives at Policy (/policy). Open a policy for its detail page: a single unified editor, not a set of tabs. The plain-English sentence sits at the top; Who it applies to, What it does, Where it applies, and (for a CHALLENGE verdict) The approval journey sections below it let you edit each part directly, with an End-user preview alongside showing what the agent's human sees. A Decision log link jumps to this policy's filtered activity (the allow / block / challenge log), and an Advanced · Cedar section at the bottom shows the raw Cedar, with hand-editing available when the rule is structurally editable.
Verdicts
A rule's @verdict(...) sets what happens to a matching request:
These map to the engine's five-verdict Decision (Permit / Block / Challenge / Redirect / Instruct), orthogonal to the enforce/observe mode covered in Enforce vs. observe below. (Stored decisions may still show the legacy wire values allow and log from before this vocabulary split: read allow as Permit and log as Permit + Observe mode. There is no REWRITE verdict.)
The HTTP response a verdict produces depends on which surface enforces it, so the two shipped surfaces are listed separately:
Don't hard-code a status per verdict. INSTRUCT alone returns 401, 422, or 200 depending on which
surface you deployed and whether the caller self-identifies. See INSTRUCT by
surface. The 422 is not a paper mapping: it is what the local-engine
middlewares return today. The middleware's block shape also depends on content negotiation. See
Response shape. Read the verdict from the
__checkpoint_verdict cookie or the X-Checkpoint-* headers rather than inferring it from the
status code.
Every runtime (Gateway and every middleware SDK) verifies CHALLENGE and INSTRUCT in-process;
neither verdict requires the Gateway to be reachable. What the Gateway
adds is topology, not capability: it challenges before traffic reaches your origin, so
non-cooperating agents never touch your infrastructure, and it's the only surface that captures
TLS fingerprints. In-app middleware emits the same challenge from your application server instead
(see Middleware and the .NET Cedar
cookbook for per-SDK support), so unverified traffic still
reaches your infrastructure before being turned away. See Deployment
Architecture for the full comparison.
Choosing a verdict
Match the verdict to the threat model of the endpoint. Because policies are allow-by-default, you write rules for the traffic you want to restrict.
BLOCK
Refuse to serve the request (a 403 at the Gateway; see the table above for the middleware shape). Use it when the endpoint is strictly not for agents (competitor scraping of proprietary content, admin surfaces, endpoints that cost money per request) or as a short-term block during an incident (e.g. an AI crawler hammering your origin).
Not a good fit for a general content site: a blanket block will catch legitimate AI agents your users are delegating to. Prefer REDIRECT or INSTRUCT for soft-to-cryptographic enforcement.
REDIRECT
Return a 302 to a URL you choose: a consent page, a sign-in flow, a pricing page, or your hosted Checkpoint consent page. It's soft enforcement: "you can get what you need, just through this other flow." The target accepts absolute URLs (https://acme.com/for-ai) and same-origin paths (/ai-welcome), so one policy can cover staging and production.
Not a good fit for sensitive endpoints: a 302 is trivial for a sophisticated agent to ignore. Use INSTRUCT there.
CHALLENGE
Require the agent to satisfy a consent or approval step before proceeding. Two shapes:
- Consent: the agent must carry the scopes the rule demands (
@scopes(...)+ acontext.granted_scopescarve-out). Use it to gate specific tools or data behind explicit user consent. - Step-up approval: a rule carrying
@approvers(...)(+ optional@quorum("N")) is read by the MCP Gateway's approval-quorum step-up, which requires a rule scoped by the Cedaraction/resourceentity, notresource.path(see the caveat in Common patterns). On the HTTP edge Gateway and the Next.js/Express/.NET SDK middleware, the same rule still just produces an ordinaryCHALLENGE; those hosts don't check the quorum.
The Delegation challenge wire
A CHALLENGE verdict on the Gateway registers the step-up with your Authorization Host, then returns the wire this project implements from draft-kya-http-02 §8.1, an unpublished IETF draft this project tracks and implements, not a ratified standard. The 401 carries two ordered WWW-Authenticate field values:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Delegation realm="api.example.com", sid="kya-...", consent_uri="https://auth.example.com/consent?sid=kya-...", pickup_uri="https://auth.example.com/api/delegation/pickup/kya-...", interval="3", kind="delegation"
WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"
Retry-After: 3
Content-Type: application/json
Cache-Control: no-storeDelegation comes first, because it's what this request actually needs: a registered step-up carrying the sid / consent_uri / pickup_uri your Authorization Host just minted for this caller (scope and min_assurance params are added when the policy sets them). Bearer resource_metadata="..." follows as ordinary RFC 9728 protected-resource discovery, the same pointer any plain OAuth client gets from a protected endpoint. Keep the two straight: only Delegation carries this request's registered step-up; Bearer is a courtesy for clients that only understand plain OAuth discovery, not a second way to satisfy the challenge.
Both envelopes (the 401 above and the negotiated 200 for cooperative agent fetchers, see delegationChallengeMode in the verdict table above) carry the identical JSON body. The 200 drops WWW-Authenticate and Retry-After entirely; a 200 that still carried WWW-Authenticate would get reinterpreted as a 401 by some fetchers, which defeats the reason the dual envelope exists.
Registration is required. Nothing on this path invents sid, consent_uri, pickup_uri, or the proof. Those values only exist once your Authorization Host has registered the step-up; the code that renders the challenge is synchronous and holds none of them on its own. When registration is absent, times out, or the Authorization Host returns something that fails validation, the response is a retryable 503 instead of a downgraded or fabricated challenge:
HTTP/1.1 503 Service Unavailable
Retry-After: 1
Cache-Control: no-store
{ "error": "kyaos/status-pending" }Retry after the interval. A 503 here means the Authorization Host hasn't caught up yet, not that the request was denied.
Deployments still on the Bearer-only wire (RFC 9728 Bearer discovery alone, no Delegation
field) can keep it temporarily with KYA_HTTP_CHALLENGE=false on the Gateway, or
EnableDelegationChallengeWire=false on .NET (see the .NET Cedar
cookbook). Both opt-outs are scheduled for removal on
2026-12-01. Migrate before then.
INSTRUCT
Return a KYA-OS challenge that requires a cryptographic identity on retry. Use it for sensitive work (payments, data exports, authenticated APIs, admin actions) where you want cryptographic assurance rather than best-effort detection. It is bypass-proof for agents that cannot forge a valid signature, and it defeats tool-delegation evasion (a request proxied through a third-party tool cannot carry the proof).
The idea behind the dual envelope is that AI-agent fetchers collapse 4xx responses and never show the model the body, so a plain 401 is invisible to exactly the caller it is meant to instruct. A cooperative, self-identifying agent therefore gets a body-readable 200 carrying the same body and no WWW-Authenticate. Envelope selection is a cooperative-UX bridge, never an access control: access stays gated on detection plus delegation verification either way.
INSTRUCT by surface
Only two of the five surfaces implement that negotiation. Deploy against the surface you actually run, not against a uniform story:
INSTRUCT never carries WWW-Authenticate, on any surface: it isn't a registered policy Delegation step-up, so it must not advertise an auth scheme it can't back. The instruction lives in the JSON body (mcp_i, user_action_required) and the Link / KYA-Auth-Url hint headers instead. Contrast this with CHALLENGE, which does emit WWW-Authenticate, documented in The Delegation challenge wire below.
Two consequences worth stating plainly. The Gateway does not negotiate for INSTRUCT at all: its KYA_HTTP_CHALLENGE_MODE setting governs CHALLENGE only, so a cooperative agent hitting the edge still receives a 401 whose body it will not read. And .NET returns 200 unconditionally, so a client that keys off the status code will read every INSTRUCT as success; read the body or the X-Checkpoint-* headers there.
delegationChallengeMode therefore affects the three middleware rows only.
INSTRUCT is verified in-process by every runtime (Gateway and every middleware SDK), so it does not require the Gateway to be deployed. Putting the Gateway in front of your origin does add edge filtering: unverified traffic is turned away before it reaches your infrastructure, rather than reaching your application server first. Pair INSTRUCT with REDIRECT per path (INSTRUCT on /api/payments/*, REDIRECT on /) rather than as a site-wide default, so non-cooperating agents on public surfaces aren't stranded on a 401. See KYA-OS Enforcement for the full flow.
Common patterns
Real Cedar for the rules teams write most often. In Compose you'd describe these in plain language; the Cedar below is what it emits.
Block a specific agent everywhere:
@id("block-gptbot")
@verdict("BLOCK")
forbid ( principal, action, resource )
when { principal.name == "GPTBot" };Block an agent on one path (allow it elsewhere):
@id("block-scraper-on-pricing")
@verdict("BLOCK")
forbid ( principal, action, resource )
when { resource.path like "/pricing*" && principal.name == "GPTBot" };Redirect scrapers to an AI portal:
@id("redirect-scrapers")
@verdict("REDIRECT")
@redirect("/for-ai")
forbid ( principal, action, resource )
when { principal.category == "scraper" };Challenge browser-automation traffic (Puppeteer, Playwright, Selenium, HeadlessChrome, webdriver, Scrapy). These all share one detected-agent name, Automation Tools, so match on principal.name; principal.category reports them as the generic bot, which would also catch Googlebot and curl:
@id("challenge-headless")
@verdict("CHALLENGE")
forbid ( principal, action, resource )
when { principal.name == "Automation Tools" };Require consent (scopes) for an agent on a path, unless it already has them:
@id("challenge-chatgpt-on-account")
@verdict("CHALLENGE")
@scopes("settings:read settings:write")
forbid ( principal, action, resource )
when { (resource.path == "/account" || resource.path like "/account/*") && principal.name == "ChatGPT" }
unless { context.granted_scopes.contains("settings:read") && context.granted_scopes.contains("settings:write") };Add an approval-quorum annotation for a sensitive action (only enforced today by the MCP Gateway, and only for a rule it can match, see the caveat below):
@verdict("CHALLENGE")
@quorum("2")
@approvers("did:web:acme:approvers:alice did:web:acme:approvers:bob")
forbid ( principal, action, resource )
when { resource.path == "/transfer" };The MCP Gateway's approval-quorum step-up matches a request by its Cedar action / resource entity, not resource.path. A resource.path-scoped rule like this one never triggers it there. On the HTTP edge Gateway and the Next.js, Express, and .NET SDK middleware, this rule still fires as a plain CHALLENGE: the caller gets an ordinary consent/step-up challenge, not a verified 2-of-N approval. Scope the when { … } to the action/resource facts of the MCP tool call you want to gate if you need real quorum enforcement.
Exempt verified agents from a restriction; add an unless carve-out so agents that present a valid delegation pass through:
unless { principal.delegation == "verified" }Match a single agent with principal.name == "X"; match a set with an OR-disjunction
(principal.name == "X" || principal.name == "Y"). Cedar's in operator is for entity
hierarchies, not string sets, so principal.name in [...] will not match.
Enforce vs. observe
Enforcement has an orthogonal mode that controls whether a matched verdict actually acts:
- Observe: evaluate the policy and record what would have happened (the dashboard shows "Would have been: Block(…)"), but let every request through. Nothing is blocked.
- Enforce: apply the verdict for real, in the per-surface shape given in Verdicts above.
Where your enforcement surface supports it, the recommended rollout is to run in observe, watch the dashboard for a week or two, then flip to enforce once the verdicts look right.
How you select the mode depends on where you enforce. Middleware SDKs take it as a config option: enforcementMode: 'observe' on withCheckpoint (see Middleware), which really does pass every request through unblocked. Checkpoint for .NET has no project-wide equivalent. OnAgentDetected only sets the local fallback action for a detected agent that no dashboard policy, composed Cedar rule, or credential check has already denied. Log (the default) and Allow pass those requests through (Log also writes a log line), but a policy verdict or a failed credential/delegation check can still Block, Challenge, Redirect, or Instruct the request regardless of what OnAgentDetected is set to.
The Gateway has no enforcementMode switch, and there is no log Cedar verdict: log belongs to the legacy structured path-rule layer, not to Cedar (see Observing before you enforce for how to use it there). To rehearse a Cedar rule before it takes effect on the Gateway, use Run dry test in Compose, which replays the policy against your last 7 days of traffic through the real engine without affecting any traffic, then deploy once the matched set looks right.
An unrecognized @verdict fails closed to BLOCK. The engine matches exactly CHALLENGE,
REDIRECT, and INSTRUCT; a forbid rule carrying any other annotation value
(@verdict("LOG"), @verdict("ALLOW"), or a typo like @verdict("CHALLNGE")) resolves to
Block, as does a forbid with no @verdict at all. This matters most when hand-editing raw
Cedar in the Advanced · Cedar section: a misspelling doesn't error, it blocks. Dry-run after
hand-editing.
Identity & consent
Some Cedar facts (principal.delegation == "verified", context.granted_scopes.contains(...)) come from the identity layer, not detection. That layer (sign-in methods, providers, per-tool consent) is configured under Policy → Auth in Govern; it produces the facts your Cedar policy evaluates. A common pattern is "known, verified agents welcome; unknown agents challenged or blocked", expressed with an unless { principal.delegation == "verified" } carve-out on an otherwise-restrictive rule.
Testing a policy
- Compose the rule and Run dry test, which replays it against the real engine with no traffic affected. Repeat until the matched set and verdicts look right.
- Authorize & deploy. On the Gateway this enables enforcement immediately (there's no separate "observe" deploy for a Cedar policy), so lean on dry-run rather than a live rollout there. Deploying via a middleware SDK instead, you can use
enforcementMode: 'observe'to record verdicts without applying them. - Review decisions in the dashboard, tune the rules against real traffic, and flip a middleware deployment from
'observe'to'enforce'when you're confident.
Next Steps
- Detection in Enforce Mode: the signals behind
principal.*facts - Gateway Enforcement: the edge Gateway that enforces your policy
- Middleware Enforcement: code-based enforcement
- Monitoring: track policy decisions
