policyctl
A provider-agnostic policy runtime that enforces procedural rules across Claude Code, Codex, Cursor, and your CI — at tool-call time and in the diff. One .policyctl.yml.
Prompt files (CLAUDE.md, .cursorrules) are advisory and get violated. Vendor denylists are per-model and coarse. policyctl is the missing layer: a local engine plus generated hook adapters, enforced in real time and again in CI.
Install
Requires Node ≥ 20. Install the CLI globally, or run it on demand with npx.
npm install -g @policyctl/cli
# or, no install:
npx @policyctl/cli checkQuickstart
Scaffold a policy, list its rules, wire provider hooks, then gate the diff.
policyctl init --template full
policyctl list
policyctl gen claude # or codex / cursor
policyctl check # gate the diff in CIinit --template full ships working examples for migrations, secrets, and protected files. Edit, don't invent.
CI gate
Add the check as a required status. Non-zero exit blocks the merge on violation.
name: policyctl
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npx @policyctl/cli checkHow it works
A rule has three parts: when (matchers), enforce (block | fail | warn), and scope (hook | ci | both).
- Hook surface —
policyctl eval --hookis called by each agent's pre-tool hook on every tool call. Exits2to block,1to warn,0to allow. Fast and offline. - CI surface —
policyctl checkparses the git diff (including untracked files) and applies the same rules. This is the source of truth for what the agent actually produced.
A hook rule stops the agent mid-action; a ci rule fails the build after the fact. Use both for the rules you never want to slip through either gate.
Rules reference
Matchers
All matchers in a rule are ANDed. A both rule fires on whichever surface is active.
| Matcher | Surface | Meaning |
|---|---|---|
path | hook + ci | Glob over the file the tool touches (hook) or changed files (ci). |
command | hook | Regex over a shell command (e.g. the Bash tool). |
tool | hook | Tool name (exact, or /regex/). |
diff_contains | ci | Substring that must be present in the diff. |
diff_not_contains | ci | Substring that must be absent (e.g. a generator signature). |
diff_regex | ci | Regex over the full diff text. |
diff_paths_glob | ci | Glob that must match ≥1 changed file. |
diff_paths_not_glob | ci | Glob that must match no changed file. |
Example: migrations only via the generator
rules:
- id: migrations-via-generator
description: "DB migrations must come from `make migrate`, never handwritten."
scope: both
when:
path: "db/migrations/**"
diff_not_contains: "! Generated by make migrate -- do not edit by hand."
enforce: blockCommands
| Command | Purpose |
|---|---|
policyctl init [--template <name>] | Scaffold .policyctl.yml. |
policyctl list | List rules and their scope. |
policyctl check [--from <ref> --to <ref>] | CI/diff gate (non-zero on violation). |
policyctl eval --hook | Hook evaluator (reads JSON on stdin). |
policyctl gen <claude|codex|cursor> [--print] | Generate provider hook glue. |
policyctl trace | Explain which matchers fire for a given input. |
policyctl test | Run the policy against a fixture suite. |
policyctl login / push / pull | Optional hosted control plane (org policy sync). |
Hosted API
The CLI talks to the control plane over a small REST surface. Auth is a bearer token saved to ~/.policyctl/config.json.
| Method & path | Description |
|---|---|
POST /api/login | Issue a token for an email. |
POST /api/policy | Push policy as a new immutable version. |
GET /api/policy | Fetch the current policy version. |
GET /api/policy/versions | List policy versions. |
POST /api/policy/versions/:id/rollback | Restore a previous version. |
POST /api/report | Send a violation result to the feed. |
GET /api/violations | Read the violation feed. |
Phase B · control plane
The control plane is a Cloudflare Worker + D1: organizations, member roles, immutable policy versioning with rollback, and a live violation feed. Live instance: https://policyctl-server.shivamkumar10958.workers.dev.
Deploy with pnpm --filter @policyctl/server deploy after applying migrations/0001_init.sql to a D1 database. No container runtime required.
Full schema and examples: README