Policy files
A policy file is JSON that holds Cedar policies. govern.load(),
govern.reload() and watchlight policy test read it the same way, in both
lanes, and refuse the same mistakes. This page describes watchlight /
@watchlight/sdk 0.13.0 and later. Earlier releases loaded some of these
files as nothing; see breaking changes.
The three shapes
A list of policy objects:
[
{ "name": "reader", "code": "permit(principal, action == Action::\"read\", resource);" },
{ "name": "no-deletes", "code": "forbid(principal, action == Action::\"delete\", resource);" }
]
An object that holds the list under "policies":
{
"policies": [
{ "name": "reader", "code": "permit(principal, action == Action::\"read\", resource);" }
]
}
A single policy object, which loads as one policy:
{
"id": "allow-read",
"name": "allow-read",
"description": "Permit only read-oriented tools.",
"code": "permit(principal, action, resource) when { context.mcp.tool == \"get_file_contents\" };"
}
Each policy object needs a non-empty string code, the Cedar source. name is
optional; when present it must be a string, and it is how errors and the trail
name the policy. Other keys, such as id and description, are ignored. An
active key, when present, must be true.
One file for the governor and the MCP PEP
The MCP PEP (watchlight-mcp) reads exactly one policy
object from each file, which is the third shape above. It checks that object
more strictly than the governor does, so a file can be shared between the two
only when it meets both sets of rules:
| Key | Governor (govern.load) | MCP PEP |
|---|---|---|
code | required, non-empty string | required, string |
name | optional string | required string |
id | ignored | optional string |
description | ignored | optional |
active | must be true when present | optional, defaults to true |
The difference that catches people out is name. A file that has only code
loads in the governor, but the PEP refuses to start with it: serve() raises
an error saying the policy does not match the schema because the name field
is missing. To be safe, give every shared file both name and code, add id
and description if you want them, and leave active out. The Developer
Edition's
examples/mcp.policy.json
has that shape:
{
"id": "allow-github-read",
"name": "allow-github-read",
"description": "Permit only read-oriented GitHub MCP tools; everything else (delete_repository, transfer, admin) is denied by default (fail-closed).",
"code": "permit(principal, action, resource) when { context.mcp.tool == \"search_repositories\" || context.mcp.tool == \"get_file_contents\" || context.mcp.tool == \"list_issues\" };"
}
from watchlight import govern
govern.load("mcp.policy.json") # the same file the PEP takes in policy_files
The list shape and the {"policies": [...]} shape are only for the governor
and watchlight policy test. The PEP refuses to start with a file in either of
those shapes, so give it one file per policy.
Before 0.13.0, govern.load() loaded a single-object file as nothing. From
0.13.0 it loads as one policy. If you kept such files beside an agent, check
what each one permits before upgrading. This is the one change in 0.13.0 that
can widen a decision.
What a load refuses
A load never ends with nothing loaded and no error. In each of the cases below, the load raises an exception (Python) or throws an error (TypeScript) that names the file, and the governor is left exactly as it was:
| The file | Python | TypeScript |
|---|---|---|
| does not exist | FileNotFoundError | Error with code: "ENOENT" |
| is a directory | IsADirectoryError | Error with code: "EISDIR" |
| exists but cannot be read | the matching OSError, e.g. PermissionError | Error with the system's code, e.g. EACCES |
| is not valid UTF-8, or not valid JSON | ValueError | Error |
has an unrecognised shape, or both "policies" and "code" | ValueError | Error |
has an entry without a non-empty string code | ValueError | Error |
has a policy marked "active": false | ValueError | Error |
holds no policies ([], {"policies": []}) | ValueError | Error |
An unreadable path is reported as unreadable, never as missing. A JSON error gives the line and column. No message quotes the file's contents; at most it names the policy.
"active": false is refused because a governor has no inactive state: every
policy it holds is enforced. Remove the policy from the file instead.
An empty set, on purpose
An empty file is refused because a governor with no policies denies every call, and that looks exactly like a policy doing its job. To load an empty set deliberately, opt in:
govern.load("policies.json", allow_empty=True)
govern.load("policies.json", { allowEmpty: true });
govern.reload() has no opt-in: replacing the policies with nothing would deny
every governed call in the process, so it always refuses an empty set.
A file that might not exist yet
A missing file used to be a no-op that loaded once the file appeared. Check for it first:
if path.exists():
govern.load(path)
A policy that does not compile
A policy with a Cedar error is never skipped silently. When the error is reported depends on the language.
-
Python.
load()compiles the whole file into a scratch engine first. A Cedar error raisesPolicyCompileError(a subclass ofRuntimeError, with.policyand.source) and adds nothing from the file. It is whole file or nothing: a shape, entry, annotation or compile error loads none of the file. -
TypeScript. The engine compiles asynchronously, so the error surfaces at
await govern.ready()or at the first decision. From then on every decision,scope()andtest()throwPolicyCompileError, naming the policy and its file, until areload()succeeds. The governor never decides on a set with a policy missing. Awaitready()to see the error at start-up:await govern.load("watchlight.policy.json").ready(); // throws PolicyCompileError
A file that failed to load is not remembered, so it loads once you fix it,
without force.
In CI
watchlight policy test reads policyFile with the same rules. It exits 2
on a suite that cannot run as written, including a policyFile that is
missing, malformed or empty, a policy that does not compile, and a suite that
declares no policies at all. See testing your policies.
Assert at start-up that the governor holds what you expect:
govern.load("watchlight.policy.json")
assert govern.has_policies, "no policies loaded — every call would be denied"
Next
- How policy works — the Cedar model the
codeis written in. - Using the governor —
load,force,source_idandreload. - Govern an MCP server — the PEP that reads the same file.