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 field | Becomes the Cedar entity | Referred to in a policy as |
|---|---|---|
principal | User::"<value>" | principal, or principal == User::"alice" |
action | Action::"<value>" | action, or action == Action::"read" |
resource | Resource::"<value>" | resource, or resource == Resource::"doc-1" |
context | a record | context.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)
permitgrants;forbidblocks.- Each of
principal/action/resourceis 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 fromcontext.
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
permitmatches it and noforbiddoes.forbidalways 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 intent | Decision |
|---|---|
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 field | From the declaration |
|---|---|
context.execution.intent_objective | intent.objective |
context.execution.intent_declared | true 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 user | principal == User::"alice" |
| Match any user | principal (bare) |
| Match one action | action == Action::"read" |
| Match one resource | resource == Resource::"doc-1" |
| Require a condition | when { context.mfa == true } |
| Exclude a condition | unless { context.quarantined == true } |
| Match a string pattern | when { context.path like "public/*" } |
| Numeric guard | when { 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.
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 withcontextinstead: pass the memberships on the request (context.groups.contains("admins"),context.role == "editor") and gate on them. Entity-hierarchyinbacked 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 incontextinstead.
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:
| Capability | Developer Edition | Watchlight Beacon (Enterprise) |
|---|---|---|
| Real Cedar decision engine | ✅ | ✅ |
| Registry-driven identity, RBAC & arbitrary-depth tenant hierarchy | — | ✅ |
Trusted entity attributes & role/group hierarchy — principal 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.