policyctl

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.

i
Why not just a prompt?

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.

terminalbash
npm install -g @policyctl/cli
# or, no install:
npx @policyctl/cli check

Quickstart

Scaffold a policy, list its rules, wire provider hooks, then gate the diff.

terminalbash
policyctl init --template full
policyctl list
policyctl gen claude      # or codex / cursor
policyctl check           # gate the diff in CI
Start here

init --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.

.github/workflows/policy.ymlyaml
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 check

How it works

A rule has three parts: when (matchers), enforce (block | fail | warn), and scope (hook | ci | both).

!
Hook vs CI

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.

MatcherSurfaceMeaning
pathhook + ciGlob over the file the tool touches (hook) or changed files (ci).
commandhookRegex over a shell command (e.g. the Bash tool).
toolhookTool name (exact, or /regex/).
diff_containsciSubstring that must be present in the diff.
diff_not_containsciSubstring that must be absent (e.g. a generator signature).
diff_regexciRegex over the full diff text.
diff_paths_globciGlob that must match ≥1 changed file.
diff_paths_not_globciGlob that must match no changed file.

Example: migrations only via the generator

.policyctl.ymlyaml
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: block

Commands

CommandPurpose
policyctl init [--template <name>]Scaffold .policyctl.yml.
policyctl listList rules and their scope.
policyctl check [--from <ref> --to <ref>]CI/diff gate (non-zero on violation).
policyctl eval --hookHook evaluator (reads JSON on stdin).
policyctl gen <claude|codex|cursor> [--print]Generate provider hook glue.
policyctl traceExplain which matchers fire for a given input.
policyctl testRun the policy against a fixture suite.
policyctl login / push / pullOptional 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 & pathDescription
POST /api/loginIssue a token for an email.
POST /api/policyPush policy as a new immutable version.
GET /api/policyFetch the current policy version.
GET /api/policy/versionsList policy versions.
POST /api/policy/versions/:id/rollbackRestore a previous version.
POST /api/reportSend a violation result to the feed.
GET /api/violationsRead 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.

×
Self-hosting

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