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.
policies/ directory. Nothing is permitted yet — that's correct.forbid for the one outcome that must never happen, no matter what else changes.Nothing is permitted by default
$ 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
{ "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.
{ "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.
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.
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
$ 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.
