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). That model
is superseded by Cedar — everything it expressed is now a Cedar rule (see Common
patterns). 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:
| Fact | Example | Meaning |
|---|---|---|
resource.path | resource.path like "/checkout*" | The request path (== exact, like glob) |
principal.name | principal.name == "GPTBot" | The detected agent's name — sometimes a group label, not a product name (see below) |
principal.category | principal.category == "scraper" | One of human, ai_agent, bot, scraper, incomplete_data (see below) |
principal.reputation | principal.reputation.lessThan(decimal("0.7")) | Agent reputation, 0.0–1.0 — not populated on any surface today; do not gate on it (see below) |
principal.delegation | principal.delegation == "verified" | Whether a valid KYA-OS delegation was presented |
context.granted_scopes | context.granted_scopes.contains("settings:read") | Consent scopes the agent has been granted |
principal.category values
principal.category is a projection of the detection class, not the class itself. The engine emits exactly five values:
| Value | What lands here |
|---|---|
human | Plausible human visitors |
ai_agent | Interactive AI assistants (ChatGPT-User, Claude-Web, Gemini) |
scraper | AI training crawlers (GPTBot, ClaudeBot, Google-Extended, PerplexityBot) — the carve-out that lets you gate them separately from search crawlers |
bot | Everything else automated: search crawlers, dev tools (curl, wget), monitoring, social-preview fetchers, and headless browsers (Puppeteer, Playwright, Selenium, HeadlessChrome, Scrapy) |
incomplete_data | Not enough signal to classify |
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:
| Traffic | principal.name |
|---|---|
| Puppeteer, HeadlessChrome, Playwright, Selenium, webdriver, Scrapy | Automation Tools |
| curl, wget, Postman, HTTPie | Dev Tools |
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.
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.
Managing a policy
The policy list lives at Policy (/policy). Open a policy for its detail page, which has four tabs:
| Tab | What it does |
|---|---|
| Overview | The authentication & consent experience this policy produces |
| Edit | Hand-edit this policy's raw Cedar block |
| Builder | Edit the policy visually as a graph |
| Decisions | The allow / block / challenge log for this policy |
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:
| Verdict | Description | Gateway (edge worker) | SDK middleware (Next.js / Express / .NET) |
|---|---|---|---|
ALLOW | Let the request through (the baseline; also re-permits a subset) | Proxied to origin | Passes to your route handler |
BLOCK | Reject the request | 403 Forbidden | 403 JSON for API clients; browsers are sent to /blocked instead so the page can render |
REDIRECT | Send to another URL (absolute or same-origin path) | 302 + Location | 302 + Location |
CHALLENGE | Require consent or step-up approval before proceeding | 401 (consent / approval flow) | 401, or a body-readable 200 envelope for cooperative agents — see delegationChallengeMode |
INSTRUCT | Require a cryptographic KYA-OS identity | 401 + WWW-Authenticate: KYA (no negotiation) | Varies by SDK — 401, 422, or 200. See INSTRUCT by surface |
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 Bouncer consent. 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 — the request must collect a quorum of approvals from named approvers (
@approvers(...)+@quorum("N")). Use it for high-stakes actions like fund transfers.
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:
| Surface | Non-cooperative caller | Cooperative agent |
|---|---|---|
| Gateway (edge worker) | 401 + WWW-Authenticate: KYA | the same 401 |
withCheckpointApi (Next.js SaaS-gateway) | 401 + WWW-Authenticate: KYA | body-readable 200 |
withCheckpoint (Next.js edge + node) | 422 application/problem+json, no WWW-Authenticate | body-readable 200 |
Express (withCheckpoint) | 422 application/problem+json, no WWW-Authenticate | body-readable 200 |
.NET (CheckpointMiddleware) | 200 | 200 |
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") };Require a 2-of-N approval quorum on a sensitive action:
@verdict("CHALLENGE")
@quorum("2")
@approvers("did:web:acme:approvers:alice did:web:acme:approvers:bob")
forbid ( principal, action, resource )
when { resource.path == "/transfer" };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.
The recommended rollout is to deploy 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); Checkpoint for .NET reaches the same behaviour with OnAgentDetected = DetectedAction.Log. Either way the mode is recorded per detection, so the dashboard can label observed verdicts distinctly from applied ones.
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 Edit tab: 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 — replays it against the real engine with no traffic affected.
- Authorize & deploy in observe mode and review the "would have been" decisions in the dashboard.
- Tune the rules against real traffic, then flip 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
