New
Apps 23Makers 23This week 23
Promote your app−30%
Paveo screenshot 1
1views in
the last 30 days

Paveo

Paveo – Check Claude Code, Codex and Cursor commands before they run

Built with Claude Code

by paveo-dev · from INIndia · added Oct 28, 2026
no ratings0 comments
Open app Code

One set of rules for every AI coding agent your team runs.

Claude Code, Codex and Cursor ask Paveo before they run a shell command or write a file. rm -rf, git push --force, DROP TABLE and the obvious ways of editing the guard itself are refused, the agent is told why and tries something else, and every decision goes into a hash-chained record your security team can verify. It matches patterns, so it is a seatbelt, not a sandbox. The Cursor guard has not been tried in a real session yet, and by Cursor's documentation it may refuse every call (details).

Nothing leaves your machine. Paveo runs on each engineer's machine, opens no network sockets (a test fails the build if it does), and installs no other package. It is open source under Apache-2.0, except the paid audit evidence export (licence), and free for up to three agents per policy.

First, check the guards you already have: uvx paveo doctor, run in any project with Claude Code hooks, names the ones that cannot work as written, such as guards that can never block. (uvx comes with uv and fetches a suitable Python itself.) Then uvx paveo test sends those hooks 68 spellings of five dangerous actions, the way Claude Code sends a call, and names each spelling that gets past them; in CI, --expect makes the build fail the day a guard stops stopping one (details). uvx paveo test codex does the same for a project's Codex hooks, by Codex's rules (details).

pip install paveo            # Python 3.11+ (python3 --version), macOS or Linux

For a whole team: the hook, one policy, a standalone Python and paveo, all inside a root-owned /opt/paveo, for Claude Code and Codex on every engineer's machine and set in each agent's managed settings (in the one managed source you already deploy: a second source can make Claude Code skip it, see the guide); on every call the hook checks that everything in /opt/paveo is root's, file by file, so the agent cannot switch it off through any file the hook runs (it can still edit what the engineer's shell starts with, such as ~/.zshrc: the guide says what is not covered); or, without device management, the Claude Code plugin (/plugin marketplace add paveo-dev/paveo, then /plugin install paveo@paveo). docs/ROLLOUT.md has both and what each does not cover; we set it up with you if you like: talk to us.

Paveo also guards agents you build yourself (every tool call and model call checked against a policy, with a budget) and any MCP server; see guard your own agent.

Tried it? Paveo sends nothing home, so we only learn what works from you: say hi in Discussions.

Two minutes: a seatbelt for your coding agent

For Claude Code, Codex or Cursor, with no code to write. It needs Python 3.11 or newer, so run python3 --version first. The one that comes with Apple's command line tools can be 3.9, where pip says only No matching distribution found for paveo, without mentioning Python. If yours is older, put a newer one, such as Homebrew's python3.12, in the first line, and if you already ran it with the old one, rm -rf ~/.paveo first so nothing of it is left behind.

python3 -m venv ~/.paveo && ~/.paveo/bin/pip install paveo
cd /your/project
~/.paveo/bin/paveo init claude-code      # or: init codex, init cursor

From then on, before the agent runs a shell command or writes a file, Paveo checks it. rm -rf, git push --force, git reset --hard and DROP TABLE are refused, and so are the obvious ways of editing the guard's own policy, log and hook settings. The refusal goes back to the agent, which tries something else. init tests the hook it wrote before it says Done. Details, and what it cannot do, are below.

Before you trust it, replay what already happened: ~/.paveo/bin/paveo replay claude-code puts every tool call from your past Claude Code sessions in this project through the policy, and prints how many it would have refused and why. Nothing is stored and nothing from the sessions is printed.

Five minutes: guard your own agent

A policy names each agent and what it may do:

{"version": 1, "policy_id": "support",
 "agents": [{"id": "refund-bot",
             "tools": {"allow": [
               {"name": "lookup_order", "constraints": {"order_id": {}}},
               {"name": "refund",
                "constraints": {"order_id": {}, "amount_usd": {"max": "500.00"}},
                "requires": {"tool": "lookup_order", "same": ["order_id"]}}]},
             "budget": {"period": "day", "limit_usd": "5.00"},
             "models": {"allow": ["claude-sonnet-5"]}}]}

Then every tool call goes through check_tool, and a refusal goes back to the model as the tool's result, so it tries something else instead of your loop crashing:

from paveo import Paveo, PolicyDenied

# Both `with` blocks matter: the outer one closes the audit log and its anchor.
with Paveo.from_file("paveo.json", audit_path="audit.jsonl") as pf:
    with pf.session(agent_id="refund-bot", principal="user_123") as s:
        try:
            s.check_tool(name, arguments)
        except PolicyDenied as refused:
            result = refused.for_model   # e.g. "Refused by policy: refund.amount_usd.max
                                         # (constraint_violated). An argument is outside..."
        else:
            result = run_tool(name, arguments)

Here a refund over $500, a refund for an order the agent never looked up, and any tool not listed are all refused, and recorded. for_model names the rule and says what the model can do about it. It deliberately leaves out what str(refused) includes, which is how to change the policy: that advice is for you, not for an agent that might act on it.

To see a ceiling refuse with no API key and no money, clone this repository and run python examples/runaway_loop.py: it spends a $1.00 ceiling down on recorded responses and is refused before the call that would breach it.

What a policy can say

Which tools, and with which arguments. When a tool rule lists constraints, those are the only arguments that tool may be called with, and an argument carrying a constraint has to actually be supplied:

"refund": { "amount_usd": { "max": "500.00" }, "note": {} }

{"amount_usd": 250, "note": "duplicate charge"}   // allowed
{"amount_usd": 600}                               // denied — over the max
{"amount_usd": 250, "destination": "..."}         // denied — not a named argument
{"note": "..."}                                   // denied — amount_usd is constrained,
                                                  //   so it cannot be left out

A constraint you can dodge by omitting the argument is not a constraint. {} permits an argument without constraining or requiring it. not_matches refuses an argument that matches a pattern, which is how the coding-agent guard refuses rm -rf. An agent with no rule for a tool may not call it.

A tool only after another. requires permits a tool only once an earlier call in the same session, to a tool you name, had the same values for the arguments you name:

{ "name": "refund",
  "constraints": { "order_id": {}, "amount_usd": { "max": "100.00" } },
  "requires": { "tool": "lookup_order", "same": ["order_id"] } }

// refund A-1 with no lookup                  denied (requires_unmet)
// lookup_order A-1, then refund A-1          allowed
// lookup_order A-1, then refund B-2          denied

"Earlier" means a call the policy admitted, which is not one that succeeded: if the lookup itself failed, the refund is still allowed. Only strings, integers, booleans and null can be compared; any other value meets nothing. The session remembers a SHA-256 digest of the compared values, never the values, and forgets it when its with block ends. A policy whose requires could never be met does not load.

How often. "rate": {"calls": 20, "seconds": 60} refuses the 21st call to a tool inside any 60 seconds, and "repeat": {"seconds": 30} refuses the same call, every argument equal, twice inside 30 seconds: the loop that retries one failing request forever. Add "same": ["command"] to compare only the arguments named, since a model rewrites some freely, like Claude Code's description on Bash. Values are compared exactly as sent, so 42 and "42.00" are different calls: name in same the arguments a rule pins, such as an id or a currency, not an amount the agent can spell another way. Both count only calls the rules admitted, per session, so an agent's calls in two sessions are not added together. Time never runs backwards for them: they move on by how far the clock moves forward, and not at all when it steps back. They are brakes on an agent stuck in a loop, not a quota against one trying to get round them: an agent that can edit files can delete its memory. In the guard, a call counts when the hook admits it, before Claude Code asks you: one you decline still counts, so an identical retry you then approve can be refused as repeated until its window passes.

Which models, and how much. models.allow names the models an agent may call, and budget puts a ceiling on what it may spend in a period. An agent with no budget makes no model calls. See Model calls.

Try it before it enforces. Give an agent "mode": "shadow" and each call its rules would refuse is recorded as would_deny and allowed through, so you can run your agent for a day and read what the policy would have broken. Every call from that agent logs a warning while this is on. Its budget is never shadowed: the ceiling refuses as it always does.

Model calls and ceilings

A model call is checked before it is sent, and its worst case is held against the agent's ceiling until you say what it really cost. Alerts tell you after the money is gone; this refuses the call that would breach the ceiling.

request = {"model": "claude-sonnet-5", "max_tokens": 1024, "messages": [...]}

with pf.session(agent_id="refund-bot", principal="user_123") as s:
    # refused before a byte leaves if the model is not allowed, the call cannot
    # be priced, or its worst case would breach the ceiling
    with s.check_llm(request, shape="anthropic") as call:
        response = client.messages.create(**request)
        call.record(response.usage.model_dump())

    s.remaining()   # USD left, counting calls still in flight

For Anthropic, wrap_anthropic does those three steps for you:

import anthropic

with pf.session(agent_id="refund-bot", principal="user_123") as s:
    client = s.wrap_anthropic(anthropic.Anthropic())
    response = client.messages.create(**request)   # checked, sent, recorded

It offers messages.create and messages.stream, and the same under beta, and nothing else: any other attribute raises rather than letting a call out unchecked, so use the unwrapped client for those, knowingly. A call that raises is charged its worst case (Anthropic does not say which failed requests go unbilled). A stream read to its end, or closed after its usage arrived, is charged what it reported; one abandoned before that, or still open when the session ends, is charged its worst case. Leaving a stream early never reads the rest of it. It takes a sync client for now: AsyncAnthropic is refused until async_session exists.

OpenAI Chat Completions (also what LiteLLM sends for any provider) and Gemini generate_content go through check_llm the same way:

with s.check_llm(request, shape="openai") as call:
    response = client.chat.completions.create(**request)
    call.record(response.model_dump())

with s.check_llm(request, shape="gemini") as call:   # {"model", "contents", "config"}
    response = client.models.generate_content(**request)
    call.record(response.usage_metadata.model_dump())

If the request never reached the provider, call call.release() instead. A call settled neither way is charged its whole worst case, because it may have been billed. For any provider without a built-in table, declare the model's price in the policy ("prices": {"mistral-large-3": {"input_per_mtok": "2.00", "output_per_mtok": "6.00"}}). Then either send it in OpenAI's shape (shape="openai") and record the response as OpenAI returns it, or use shape="generic" for any other shape and record {"input": n, "output": m}.

For OpenAI, an unset service_tier is reserved at Fast, since a project setting can make it Fast, and the whole response tells us the tier really used, so it is charged at that.

What it will refuse, and why. Anything whose cost it cannot bound from the request: images, audio, video and files; content sent by URL or file id; PDFs; server-run tools such as web search or code execution; any request parameter it has not verified; and any call without an output cap (max_tokens, max_completion_tokens or max_output_tokens), unless the policy sets defaults.assumed_max_output_tokens. Each refusal names the part of the request and says what to do. If one of these is what your agent does, open an issue: it is the most useful thing you can tell us.

If a call ever costs more than was reserved for it, Paveo stops admitting calls until the process restarts: it means an estimate under the ceiling was wrong, and a guard that knows it is wrong should not keep guessing. (Not for a call reserved on your policy's assumed_max_output_tokens, which caps nothing and is approximate by design.) The checks are plain functions that never wait, so they work inside async code as they are.

What is recorded: identities, decisions, reasons, costs and token counts, hash-chained, on your disk. Never a prompt, a completion or a tool argument.

Built: Paveo.from_file, from_policy, session, check_tool, check_llm, wrap_anthropic, remaining, PolicyDenied.for_model, the policy file with shadow mode, not_matches, requires, rate and repeat, verify_chain, the paveo command (init, the guard for Claude Code, Codex and Cursor, stop, resume, replay, learn, evidence, mcp, doctor, test), starter policies, and readers for Anthropic, OpenAI Chat Completions and Gemini generate_content requests. Not built yet: async_session, and a budget shared across processes.

Coding agents: Claude Code, Codex and Cursor

paveo guard <agent> is a pre-tool hook. Before the agent runs a shell command or writes a file, the hook checks the call against a policy, records it, and refuses it if the policy says no.

Claude Code. init claude-code writes the starter policy to .paveo/policy.json and a hook to .claude/settings.local.json (your personal settings, kept out of git, since the hook names this machine's paveo), keeping every other setting. It then tests the hook it wrote, and asks the policy whether it refuses rm -rf, and says Done only if both pass. Start Claude Code in that folder and it is on. Running init again changes nothing. It refuses, and changes nothing, if a settings file cannot be read, if an existing paveo hook would miss Write or Edit, if any file it would write is a symbolic link, or if the project's .claude folder is the one Claude Code reads in every project, as it is in your home folder: a guard meant for one project would guard them all, so cd into the project first.

One policy for every agent in a project. Run init for a second agent in the same project (init cursor after init claude-code, say) and its starter rules are added to the .paveo/policy.json already there, so one file covers all of them. The free plan covers three agents per policy, so Claude Code, Codex and Cursor fit together; init for an agent past three says so and changes nothing.

If you move or delete that virtualenv, the hook stops working silently: Claude Code treats a hook it cannot run as one with no objection. So after any change, run ~/.paveo/bin/paveo guard --selftest in the project. It checks every configured hook really refuses, fails one Claude Code would skip for some calls (a matcher that misses file edits, a type other than "command", "args", "shell": "powershell", "async": true, an "if" filter, or a timeout under 10 seconds), and says so if a paveo hook sits in your global settings (~/.claude/settings.json). There it runs in every project, and the hook init writes refuses every call in a project with no .paveo/policy.json.

  • Panic button: ~/.paveo/bin/paveo stop, run in the project, refuses every call until ~/.paveo/bin/paveo resume. init prints the exact line for your install. The starter policy refuses paveo resume from the agent itself.
  • Allowing is silent. When the policy has no objection, the agent's own permission prompts still apply. The hook only ever refuses.
  • Anything unexpected inside the hook refuses the call, and so does taking longer than 4 seconds. Claude Code lets a call through when a hook fails or times out, so the hook never does either. A normal check takes about 60 ms (Overhead). Leave the hook's timeout at 10 seconds or more.
  • Every decision goes in .paveo/audit.jsonl, hash-chained, and many hooks running at once share it safely.
  • Start in shadow mode ("mode": "shadow" in the policy) to see what it would refuse in your own work before it refuses anything.

Replay and learn. ~/.paveo/bin/paveo replay claude-code, run in the project, puts each tool call from your past Claude Code sessions through .paveo/policy.json. It prints one screen: how many calls it would have refused, the pattern that refused each, and what the model calls would have cost at API list prices (a subscription is billed differently). Add a budget to the policy and it also says what that ceiling would have refused, counting each call at what it cost. Pass ~/.claude/projects to replay every project instead of this one. Nothing is stored, and nothing from the sessions is printed: counts, sums, and names your policy already holds.

~/.paveo/bin/paveo learn claude-code --from-history writes .paveo/policy.learned.json: your policy, plus the name of every tool that ran in those sessions and every argument it ran with, each permitted unchecked. Names only, never a value, and only from calls that ran without error. It never writes over policy.json: read it, then move it over yourself. The hook init installs sees Bash, Write, Edit and NotebookEdit, so a rule for any other tool matters only if you widen its matcher. replay and learn read Claude Code's sessions only.

Check the hooks you already have. paveo doctor, run in a project, reads .claude/settings.json, .claude/settings.local.json, ~/.claude/settings.json and any script a hook in them names, and names each hook that cannot work as written, with the fix. It looks for two faults it can prove from the text: a hook that reads $CLAUDE_TOOL_INPUT, $TOOL_INPUT, $CLAUDE_FILE_PATH or a relative, which Claude Code does not set and its docs have never listed (a hook gets the call as JSON on stdin), so it always reads an empty value; and a PreToolUse guard that exits 1 and never 2, which Claude Code treats as a non-blocking error, so it never blocks. That is judged only when every command the hook runs is a shell tool such as grep, jq or echo, or a shell script it read: anything else may decide from out of sight, so it is left alone. Other faults, a missing jq among them, are not looked for.

Description taken from the repository README.

Ratings

–
0 ratings
5 0
4 0
3 0
2 0
1 0

Rate this app

Sign in to rate

Comments 0

Sign in to comment

No comments yet.

Related apps

#claude-code · #security · #dev-tools