Skip to content
Browse documentation

Writing your first policy

Start from default-deny, permit exactly what an agent needs, and test the rule set like code before you merge.

A policy set that starts too loose stays too loose, because tightening a rule later means finding every caller that depended on the slack. Start narrow instead — it's cheaper to add a permit than to discover you needed a forbid.

Scaffold
An empty policies/ directory. Nothing is permitted yet — that's correct.
Permit one thing
The single narrowest rule an agent actually needs today.
Add a guardrail
A forbid for the one outcome that must never happen, no matter what else changes.
Test it
Unit tests that assert on the verdict, not on agent behaviour.
Simulate
Run the candidate set against real recent traffic before merging.

Nothing is permitted by default

TerminalShell
$ echo '{ "rules": [] }' > cirvix.policy.json$ cirvix policy --policy cirvix.policy.json   0 rules $ cirvix check --policy cirvix.policy.json --action fs.read --resource src/app.ts   DENY  fs.read /repo/src/app.ts  rule    — no rule matched (default deny)

Permit exactly one thing

cirvix.policy.jsonJSON
{  "rules": [    {      "name": "allow-workspace-read",      "effect": "permit",      "agents": ["pr-triage"],      "actions": ["fs.read"],      "resources": ["*"],      "when": [        { "path": "path.insideWorkspace", "op": "eq", "value": true },        { "path": "environment", "op": "eq", "value": "local" }      ],      "reason": "Read inside the workspace root, in local development."    }  ]}

Add a guardrail forbid rule

This is the rule that has to survive every future change to the file above. Because forbid always wins, it does — no permissive rule added next quarter can override it.

cirvix.policy.json — the guardrailJSON
{  "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."}

The patterns are globs, not regular expressions: * stays within one path segment, ** crosses segments, and every other character is a literal — so **/.env matches .env and config/.env but not xenv. The resource is canonicalized before matching, so ./src/../.env is the same resource as .env.

Note
Order in the file doesn't matter. forbid always wins over permit for the same call, regardless of which rule Cirvix loaded first — which is what makes a guardrail safe to leave in place while other people add permissive rules around it.

Unit test the policy

Policy is code — it gets tests, and they run without a network, a model, or a running control plane.

policy.test.tsTypeScript
import { evaluate } from "@cirvix/agent-control/testing"; test("agents cannot read dotenv files", async () => {  const decision = await evaluate({    policyDir: "./policies",    agent: "pr-triage",    action: "fs.read",    resource: "/repo/.env.production",    context: { environment: "local" },  });   expect(decision.verdict).toBe("deny");  expect(decision.policy).toBe("deny-dotenv-read");}); test("workspace reads are permitted locally", async () => {  const decision = await evaluate({    policyDir: "./policies",    agent: "pr-triage",    action: "fs.read",    resource: "/repo/data/input.csv",    context: { environment: "local" },  });   expect(decision.verdict).toBe("permit");});

Simulate against real traffic before merging

TerminalShell
$ node --test policy.test.mjs   unchanged            41,102  would now permit         14   ← review these  would now deny           92   ⚠ 14 calls that were denied would now be permitted. Exiting non-zero.

Ship it

Wire the policy tests and expectNoLoosening into CI as a required check on any change to the rule set, the same way you'd gate a schema migration. Where a recorded run exists, cirvix replay --policy candidate.json is the stronger check — it exits non-zero when any decision would change.

Edit this page on GitHub
Was this page helpful?