Skip to main content

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:

watchlight.policy.json
[
{ "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":

watchlight.policy.json
{
"policies": [
{ "name": "reader", "code": "permit(principal, action == Action::\"read\", resource);" }
]
}

A single policy object, which loads as one policy:

allow-read.json
{
"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:

KeyGovernor (govern.load)MCP PEP
coderequired, non-empty stringrequired, string
nameoptional stringrequired string
idignoredoptional string
descriptionignoredoptional
activemust be true when presentoptional, 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:

mcp.policy.json
{
"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.

A single-object file now grants what it says

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 filePythonTypeScript
does not existFileNotFoundErrorError with code: "ENOENT"
is a directoryIsADirectoryErrorError with code: "EISDIR"
exists but cannot be readthe matching OSError, e.g. PermissionErrorError with the system's code, e.g. EACCES
is not valid UTF-8, or not valid JSONValueErrorError
has an unrecognised shape, or both "policies" and "code"ValueErrorError
has an entry without a non-empty string codeValueErrorError
has a policy marked "active": falseValueErrorError
holds no policies ([], {"policies": []})ValueErrorError

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 raises PolicyCompileError (a subclass of RuntimeError, with .policy and .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() and test() throw PolicyCompileError, naming the policy and its file, until a reload() succeeds. The governor never decides on a set with a policy missing. Await ready() 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​