Skip to main content

permdiff

terraform plan for AI agent permission changes.

permdiff replays a corpus of recorded agent tool calls against an authorization policy at two git refs and reports which calls change decision: what becomes denied, what becomes allowed, what now needs human approval, and what could not be evaluated. It never calls a model. It replays decisions.

$ permdiff diff --base origin/main --head HEAD --traces traces/*.jsonl

permdiff: origin/main → HEAD   (4,812 calls, 2026-09-18 → 2026-09-25)
  newly DENIED             37   aws.ec2.terminate_instance
  newly ALLOWED             2   github.delete_branch          ⚠ widening
  now REQUIRE_APPROVAL    118   stripe.refund
  can't evaluate            9   missing context: principal.department
  unchanged             4,646

Status

0.1.0 is on PyPI (pip install permdiff). Progress by epic is in docs/03-epics.md; changes in CHANGELOG.md; release steps in docs/release.md.

Install

pip install permdiff

Python 3.11 or newer.

Quickstart

1. Try it on the bundled example (30 seconds).

permdiff demo

This diffs two bundled policy versions over a synthetic 200-call corpus and prints one widening group (github.delete_branch newly allowed outside prod), two tightening groups, and a can't-evaluate group where the new policy needs a department attribute the traces lack. It exits 2 because a widening was found. Nothing is downloaded and no network is used.

The demo uses the pinned OPA binary when it is installed and otherwise the bundled Python engine. To see the Rego version:

permdiff setup opa          # downloads opa 1.21.0 into your user cache, checksum verified
permdiff demo --engine opa

2. Diff your Claude Code sessions (two minutes).

Claude Code keeps every session as a transcript under ~/.claude/projects/, and permdiff reads them directly. Copy the example policy pair, put the two versions in two commits of a policy/ directory, and replay your own sessions against them:

git clone -q https://github.com/smhasan94/permdiff permdiff-src
git init -q policy-repo && cd policy-repo
cp -r ../permdiff-src/examples/claude-code/policy_base policy
git add -A && git commit -qm base && git tag v-base
rm -r policy && cp -r ../permdiff-src/examples/claude-code/policy_head policy
git add -A && git commit -qm head
permdiff diff --from claude-code --traces ~/.claude/projects/*/*.jsonl \
              --policy policy --engine python:permdiff.demo.engine:evaluate \
              --base v-base --head HEAD --principal-from env:USER

The head policy asks approval for destructive Bash commands, denies writes outside the session's working directory, and newly allows WebFetch; the report lists each as a group with counts and redacted samples, and exits 2 for the widening. Transcripts hold file contents, so keep --redact none for local runs. The claude-code-hooks importer reads a PreToolUse hook log instead (a documented, stable input); both are described in docs/importers.md.

3. Point it at your own policy and traces.

permdiff diff --base origin/main --head HEAD --policy policy/ \
              --engine opa --decision data.agent.authz.decision \
              --traces traces/*.jsonl

With --engine opa every trace becomes input, time.now_ns() returns the trace's timestamp, and the decision rule may return an object ({"effect": "allow|deny|require_approval", "reason": "...", "rule": "..."}), a boolean, or an effect string. {"effect": "error", "kind": "missing_context", "reason": "principal.attrs.department"} reports a call the policy cannot decide. Rules that stay undefined count as deny (--undefined error to flag them instead). OPA runs with http.send, net.lookup_ip_addr, rand.intn, uuid.rfc4122, and opa.runtime removed from its capabilities; a policy that uses one is reported as nondeterministic for every call unless you replay recorded values with --nd-cache decision-log.json (OPA's nd_builtin_cache shape). permdiff check validates traces and compiles both refs without diffing, which is what CI runs first.

4. Put it in CI.

name: permdiff
on: pull_request
permissions:
  contents: read
  pull-requests: write
jobs:
  permdiff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with: { fetch-depth: 0 }
      - uses: smhasan94/permdiff@v0
        with:
          traces: traces/*.jsonl

The action runs permdiff check, then diff, and upserts one PR comment marked <!-- permdiff --> that updates in place on every push. The check fails on widening (fail-on: widen). Fork PRs, whose token is read-only, get the same report in the job summary and as an artifact. sarif: "true" adds a SARIF file for github/codeql-action/upload-sarif@v4. Inputs and outputs are in docs/action.md. Save the flags once with permdiff init and commit permdiff.toml; flags override environment variables (PERMDIFF_REPORT_FAIL_ON=none) which override the file.

For a Python policy adapter instead:

permdiff diff --base origin/main --head HEAD --policy policy/ \
              --engine python:authz.permdiff_adapter:evaluate \
              --traces traces/*.jsonl

diff replays every trace against the policy at both refs and prints the summary block above. Exit code 2 means the run matched --fail-on (widen by default), 1 means permdiff itself failed, 0 means nothing matched. --head WORKTREE uses the uncommitted policy in your working tree.

Traces are JSONL, one ToolCall per line; permdiff schema toolcall prints the JSON Schema. Reports redact argument values by default; --show-args reveals named keys and --redact none shows everything for local use.

--format markdown|json|sarif and permdiff render --from-json produce the other outputs; permdiff schema report prints the JSON report's schema. Importers (permdiff JSONL, Custody, OpenTelemetry GenAI, Claude Code) are in docs/importers.md; the design is in docs/01-overview.md.

Engines

--engine cedar (pip install "permdiff[cedar]") loads *.cedar files, one *.cedarschema, and entities.json from the policy path, validates the policies against the schema, and evaluates every trace as a Cedar request built from the [cedar] templates (principal, action, resource; defaults User::"{principal.id}", Action::"{tool.name}", Resource::"{resource.id}"). The request context is the call's arguments plus its trace context, plus context.call.{principal,agent,tool,resource} and context.now (the trace timestamp as a Cedar datetime). A deny whose forbids all carry @require_approval("reason") is reported as require-approval; a missing attribute or entity is can't-evaluate naming it. permdiff demo --engine cedar runs the bundled Cedar variant.

Performance

100,000 calls end to end (import, both refs, classify, markdown) on an Apple M-series laptop, python -m bench.run --n 100000:

Engine Import Diff (both refs) Report Total Peak RSS
Python callable 2.4 s 2.8 s 0.15 s 6.5 s 799 MB
OPA 1.21.0 2.5 s 8.0 s 0.17 s 11.8 s 965 MB

Cedar evaluates 100,000 calls per ref in about 12 s through cedarpy. CI runs the benchmark on every push and fails on a 1.5x regression against bench/baseline.json.

--engine opa runs a pinned OPA binary (1.21.0, SHA-256 verified on download; override with --opa-bin or PERMDIFF_OPA_BIN).

--engine python:module.path:callable calls your own Python function with signature (call: ToolCall, policy_dir: Path) -> Decision | str for every trace. This imports and runs arbitrary code from the current environment with your permissions; point it only at code you would run directly.

License

Apache-2.0.

Release files for permdiff 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for permdiff 0.2.0
File Size Uploaded
permdiff-0.2.0.tar.gz 163.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for permdiff 0.2.0
File Interpreter ABI Platform
permdiff-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 287.1 kB

Release files / permdiff-0.2.0.tar.gz

Download URL permdiff-0.2.0.tar.gz
Size 163.3 kB
Tags Source
SHA-256 checksum
How to use checksums
be6aae6a0aaab5501bdd9f84929948605a0e9ca4795855fd31419f2899d7c2df
BLAKE2b-256 checksum
How to use checksums
cd89c2f94edbeec122dbf384ee6351f7436b8d0fdf73068ae78fea37ddef6895
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.5

Release files / permdiff-0.2.0-py3-none-any.whl

Download URL permdiff-0.2.0-py3-none-any.whl
Size 123.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
728819b0af88b46f319baf35887fdd319855fb45fbeed20ead1688325d301e84
BLAKE2b-256 checksum
How to use checksums
ecedafea0711e7783a1dbe65e3c640edfd1ae30409b95f669f1a1eecb9b0d848
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.5

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page