Policy schema
The rule format, the effects, the condition operators, and the context a rule can read.
A policy is an ordered array of JSON rules. There is no separate policy language, no compiler, and no expression evaluator — a rule is data, and every policy example in these docs is written against the schema on this page.
A rule
{ "rules": [ { "name": "deny-dotenv-read", "effect": "forbid", "actions": ["fs.read", "fs.*"], "resources": ["**/.env", "**/.env.*"], "reason": "Reading .env files is denied outside an approved secrets flow.", "remediation": "Request the value as a handle: secrets.get(\"STRIPE_KEY\")" } ]}| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | Unique. Appears in every decision, alert, and audit record |
effect | permit \| forbid \| hold | yes | What happens on a match |
agents | string \| string[] | no | Glob(s) against the agent name. Omitted means any |
actions | string \| string[] | no | Glob(s) against the action |
resources | string \| string[] | no | Glob(s) against the canonical resource |
when | condition[] | no | Every condition must hold |
reason | string | no | Shown to the agent and recorded |
remediation | string | no | The legitimate path — what lets an agent re-plan |
approvers | string[] | hold only | Who may release the call |
Three properties that are load-bearing
- **Forbid always wins.** A matching
forbidcannot be overridden by anypermit, regardless of order or specificity, and it short-circuits. This is what makes a rule set safe to extend — adding a permissive rule can never silently punch a hole through an existing prohibition. - **Default deny.** A request matching no rule is denied, with
rule: null. Fail-open is how a control plane becomes decorative the first time a rule file fails to parse. - **Resources are canonicalized before matching.**
./x/../.env,.env, and an absolute path to the same file are one resource. Rules that match raw strings are bypassed by the first traversal, and by the first agent that uses a relative path.
A fourth follows from the first two: a hold outranks a permit. If any rule says a human must see this call, no other rule quietly skips them.
Effects and verdicts
| Rule effect | Verdict | Meaning |
|---|---|---|
permit | permit | The call proceeds |
forbid | deny | The call is refused. Re-planning is the only recovery |
hold | hold | Suspended for a named human. This exact call may still happen |
Glob matching
| Token | Matches |
|---|---|
* | Any run of characters within one /-separated segment |
** | Any run of characters across segments |
? | Exactly one character that is not / |
| anything else | Itself, literally |
Matching is case-insensitive, and every other character is a literal — a . means a dot, not "any character", or **/.env would also match xenv. The matcher is a two-pointer wildcard match rather than a compiled regular expression, because the pattern comes from a rule and the input comes from whatever resource an agent named: a caller on the far side of the enforcement boundary picks the input, and a backtracking matcher has pathological ones.
Conditions
{ "name": "require-approval-destructive", "effect": "hold", "actions": ["fs.delete", "db.write", "db.migrate", "k8s.apply", "shell.exec"], "resources": ["*"], "when": [{ "path": "environment", "op": "in", "value": ["production", "prod"] }], "approvers": ["platform-oncall"], "reason": "Destructive action in production. Held for a named human."}Conditions are plain data, never expressions. eval and new Function are deliberately absent: a policy file is exactly the kind of thing that gets templated by a script, and turning it into an execution surface would make the security product the vulnerability.
| Operator | Holds when |
|---|---|
eq / ne | The value is / is not equal |
in / nin | value is an array containing / not containing it |
gt / gte / lt / lte | Numeric comparison |
matches | The value is a string matching the glob in value |
exists | true → the path is set; false → it is unset |
contains | The value is an array containing value |
supersetOf | The value is an array containing every element of value |
An unknown operator **fails closed** — the condition does not match, so a typo cannot accidentally widen a rule.
The context a rule can read
| Path | Type | Set from |
|---|---|---|
agent | string | The request's agent name |
action | string | The request's action |
environment | string | --env, or the Guard's environment |
path.insideWorkspace | boolean | Whether the canonical resource is inside the workspace root |
egress.external | boolean | Whether the resource is an http(s) URL to a non-local host |
egress.allowlisted | boolean | Whether the destination is allowlisted |
session.touchedSecret | boolean | Whether this session already read secret material |
mcp.server / mcp.tool | string | Set when the call came through the gateway |
session.touchedSecret is the one to understand. It is set once a session reads secret-shaped material and never resets — it is what makes "read a credential, then post it somewhere" fail even when both calls are individually allowed. A brokered secret handle deliberately does not set it: the agent never held the material, which is the entire point of a handle.
Actions
Policy is written against a normalized action vocabulary rather than raw tool names, so one rule covers every tool that does the same thing.
| Tool name contains | Action |
|---|---|
read, get, cat, fetch | fs.read |
write, create, put, save, edit | fs.write |
delete, remove, rm, unlink, drop | fs.delete |
list, ls, search, find, query, grep | fs.list |
exec, run, shell, command, spawn | shell.exec |
request, http, curl, browse, scrape | http.request |
apply, deploy, rollout | k8s.apply |
| *no match, via the gateway* | mcp.<server>.<tool> |
| *no match, via wrap* | tool.<name> |
Matching is on whole words delimited by start/end, ., _, or - — so read_file maps to fs.read but spreadsheet does not. Because unmatched tools fall through to mcp.* and tool.*, a rule can always name one specific tool.
Validating a rule set
$ cirvix policy --policy cirvix.policy.json $ curl -X POST $CIRVIX_API/v1/policy/validate \ -H "authorization: Bearer cvx_..." \ -H "content-type: application/json" \ -d @cirvix.policy.json