Skip to main content

How Policy Works

Watchlight decides every request by evaluating Cedar policies — small, readable permit / forbid rules. This page is the 5-minute mental model; the Examples page is the copy-paste cookbook. Every policy and output below was produced by the published watchlight-engine wheel — the results are real, not illustrative.

import json
import watchlight_engine

def new_engine(*policies):
eng = watchlight_engine.PolicyEngine()
for name, code in policies:
eng.add_policy(json.dumps({"name": name, "code": code}))
return eng

def decide(eng, principal, action, resource, **context):
resp = json.loads(eng.authorize(json.dumps({
"principal": principal, "action": action,
"resource": resource, "context": context,
})))
return resp["decision"] # "Allow" or "Deny"

1. A request is a triple (+ context)

Every authorization question is "may this principal take this action on this resource?" — plus an optional bag of context. Those three strings map to Cedar entities:

Request fieldBecomes the Cedar entityReferred to in a policy as
principalUser::"<value>"principal, or principal == User::"alice"
actionAction::"<value>"action, or action == Action::"read"
resourceResource::"<value>"resource, or resource == Resource::"doc-1"
contexta recordcontext.amount, context.mfa, …

2. Anatomy of a rule

permit(principal, action, resource)   // who / what / on-what this rule is about
when { context.mfa == true }; // optional extra condition(s)
  • permit grants; forbid blocks.
  • Each of principal / action / resource is either a wildcard (the bare word — matches anything) or an equality (== User::"alice").
  • when { … } must be true for the rule to apply; unless { … } must be false. Both read from context.

3. Default deny

With no rule that permits a request, the answer is always Deny. You open up access deliberately, one permit at a time.

eng = new_engine()                                  # no policies at all
decide(eng, "anyone", "anything", "anywhere") # -> "Deny"

4. Match a specific principal / action / resource with ==

# Role: the admin may do anything; the bare `action, resource` are wildcards.
eng = new_engine(("admin-all", 'permit(principal == User::"admin", action, resource);'))
decide(eng, "admin", "read", "doc") # -> "Allow"
decide(eng, "alice", "read", "doc") # -> "Deny"

# Verb: anyone may read, but only read.
eng = new_engine(("read-only", 'permit(principal, action == Action::"read", resource);'))
decide(eng, "alice", "read", "doc") # -> "Allow"
decide(eng, "alice", "write", "doc") # -> "Deny"

5. Condition on context with when / unless

context carries the request-time facts your policy needs — amounts, flags, paths, tiers. Values can be numbers, strings, or booleans, and strings support like glob patterns.

# Spend cap: allow only when the amount is within budget.
eng = new_engine(("limit", 'permit(principal, action, resource) when { context.amount <= 1000 };'))
decide(eng, "agent", "transfer", "funds", amount=500) # -> "Allow"
decide(eng, "agent", "transfer", "funds", amount=5000) # -> "Deny"

# Kill-switch: allow this agent, unless it has been quarantined.
eng = new_engine(("agent",
'permit(principal == User::"agent", action, resource) unless { context.quarantined == true };'))
decide(eng, "agent", "read", "doc", quarantined=False) # -> "Allow"
decide(eng, "agent", "read", "doc", quarantined=True) # -> "Deny"

6. Combining rules: deny-overrides

Load as many policies as you like. The engine's rule is simple and safe:

A request is allowed iff some permit matches it and no forbid does. forbid always wins.

eng = new_engine(
("admin-all", 'permit(principal == User::"admin", action, resource);'),
("no-deletes", 'forbid(principal, action == Action::"delete", resource);'),
)
decide(eng, "admin", "read", "doc") # -> "Allow" (permitted, nothing forbids it)
decide(eng, "admin", "delete", "doc") # -> "Deny" (forbid overrides the admin grant)

This is why a forbid is the right tool for a hard boundary (a compliance rule, a quarantine) that must hold no matter what else is granted.

7. Govern by goal and intent

An agent's goal and intent are declared in the request, not the policy — but your policy can absolutely decide based on them. There are two ways, and they compose. Both outputs below are real (produced by the published watchlight-engine).

a. As context — your policy reads it directly

Put a goal or intent anywhere in context and gate on it like any other field:

eng = new_engine(("intent-scoped",
'permit(principal, action, resource) '
'when { context.intent == "data_analysis" && context.goal == "quarterly-report" };'))

# request: {"principal": "...", "action": "...", "resource": "...",
# "context": {"intent": "data_analysis", "goal": "quarterly-report"}}
# -> Allow (both match)
# omit or change either -> Deny

This is the simplest form: you supply the strings and your policy decides.

b. As a structured declaration — engine-enforced, and Cedar-readable

Declare intent as a structured object on the request (a sibling of context, not inside it):

request = {
"principal": "agent", "action": "transfer", "resource": "funds", "context": {},
"intent": {"category": "data_analysis", "objective": "quarterly-report",
"risk_level": "low"},
}

Two things happen. First, the engine's intent layer applies built-in rules above Cedar — verified against the wheel:

Declared intentDecision
category: "financial_operation"Deny + human-approval escalation
risk_level: "critical"Deny + human-approval escalation
category: "system_administration"Deny unless a justification is given
category: "security_compliance"Deny unless a justification is given

Second, the declaration is surfaced into context so your Cedar policies can read it too:

eng = new_engine(("objective-gate",
'permit(principal, action, resource) '
'when { context.execution.intent_objective == "quarterly-report" };'))
# with intent.objective == "quarterly-report" -> Allow
Cedar fieldFrom the declaration
context.execution.intent_objectiveintent.objective
context.execution.intent_declaredtrue when an intent was declared
context.execution.intent_provenance"declared" vs "absent"

Use (a) for your own free-form gating; use (b) when you want the engine's category/risk governance and an auditable, uncounterfeitable intent trail. A policy such as forbid(...) unless { context.execution.intent_declared == true } refuses any action taken without a declared intent at all.

Cheat sheet

You want to…Write
Match a specific userprincipal == User::"alice"
Match any userprincipal (bare)
Match one actionaction == Action::"read"
Match one resourceresource == Resource::"doc-1"
Require a conditionwhen { context.mfa == true }
Exclude a conditionunless { context.quarantined == true }
Match a string patternwhen { context.path like "public/*" }
Numeric guardwhen { context.amount <= 1000 }
Hard block (wins over any permit)forbid(...)

The full Cedar operator set is available

Watchlight embeds real Cedar 4.9 — not a subset or a look-alike. Every standard Cedar operator works in your policies: comparison (==, !=, <, <=, >, >=), logical (!, &&, ||), arithmetic (+, -, *), the if … then … else … expression, has (attribute presence), like (glob matching — * is the wildcard, _ is a literal underscore), is (entity-type test), the set operators .contains() / .containsAll() / .containsAny(), and the extension functions decimal() and ip().isInRange().

The authoritative reference for all of them is the Cedar documentation: Cedar syntax — operators and functions. Anything valid there is valid in a Watchlight policy, with one caveat below.

String matching uses like, not .startsWith()

Cedar has no .startsWith() / .endsWith() / .contains() methods on strings — those are a common mistake and a policy that uses them fails to load. Use like glob patterns instead: like "analyze_*" (prefix), like "*_report" (suffix), like "*write*" (substring). .contains() is only for sets, e.g. resource.tags.contains("pii").

What the Developer Edition does not evaluate

The DE engine works from the request triple above — it has no entity store, so policies that depend on entity relationships don't resolve here:

  • Groups / role & tenant hierarchy (in)principal in Group::"admins" parses, but there's no membership data for Cedar to consult, so it can't match. Model grouping with context instead: pass the memberships on the request (context.groups.contains("admins"), context.role == "editor") and gate on them. Entity-hierarchy in backed by a real identity store is on our roadmap — see below.
  • Custom entity types & attributes — principals, actions, and resources are User / Action / Resource; there are no app-defined entity types or attributes on the entities themselves. Put attributes in context instead.

in (entity hierarchy) — coming

Cedar's in operator tests membership in a group or hierarchy (principal in Group::"admins", resource in Folder::"public"). Resolving it requires an entity store that knows who belongs to what. That store — wiring a registry-backed identity and resource model into Cedar's entity graph — is a roadmap item, so for now express grouping through context sets and RBAC-style fields as shown above. Action groups (action in [Action::"read", Action::"write"], which are defined inline, not in an entity store) already work.

Where Watchlight Beacon (Enterprise) goes further

DE gives you the real Cedar decision engine. Enterprise wraps that same engine in the runtime governance a fleet of production agents needs — capabilities that are live today, not roadmap:

CapabilityDeveloper EditionWatchlight Beacon (Enterprise)
Real Cedar decision engine
Registry-driven identity, RBAC & arbitrary-depth tenant hierarchy
Trusted entity attributes & role/group hierarchyprincipal in Group::"…", and attributes like principal.clearance / resource.classification attested by signed claims and the registry (not caller-asserted)context-supplied (your app's own inputs)✅ tamper-evident, entity-store-backed
Signed, tamper-evident lineage (exec-graph)local audit log✅ cryptographically signed
Drift / anomaly detection on agent behavior
Containment — quarantine, terminate a run, sever a sub-agent subtree, revoke trust
Human-in-the-loop approval workflow (route, hold, resume)decision surfaced✅ full workflow
Sub-agent scope attenuation (strict-subset confinement)
Multi-tenant, fleet-wide governance & policy management

See DE vs Enterprise for the full comparison.

Next

  • Examples — nine ready-to-adapt policy patterns (RBAC, ABAC, path rules, quarantine, tool-gating).
  • Govern an agent — put these decisions in front of a real agent's tool calls.