portcullis
A per-action autonomy policy layer for AI agents.
pip install portcullis
Decide, for every tool call, whether an agent may act on its own — based on the action's reversibility and blast radius, not the model's capability.
Everyone is building more capable agents. Almost no one is building the layer that decides what an agent is allowed to do without asking. This is that layer.
The idea in one table
Two properties of an action decide everything:
- Reversibility — can the effect be undone? (reading a row is reversible; sending an email is not)
- Blast radius — how far do the consequences reach? (one record vs. a whole table)
Map every tool call onto the grid, and the cell decides the outcome:
| Low blast radius | High blast radius | |
|---|---|---|
| Reversible | auto-execute | execute + audit |
| Irreversible | execute + audit | require approval |
The point most designs miss: capability is irrelevant to this decision.
A smarter model does not move delete_production_table out of the "require
approval" cell. That is what makes this governance and not a guardrail.
Quickstart
from portcullis import (
Policy, GovernanceEngine, Reversibility, BlastRadius, CLIApproval,
)
policy = (
Policy()
.register("read_customer", Reversibility.REVERSIBLE, BlastRadius.LOW)
.register("send_email", Reversibility.IRREVERSIBLE, BlastRadius.HIGH)
)
engine = GovernanceEngine(policy, approval=CLIApproval())
# reversible + low -> runs immediately
engine.guard("read_customer", read_customer, "C-4821")
# irreversible + high -> pauses and asks a human; raises ActionDenied if declined
engine.guard("send_email", send_email, to="billing@acme.com", subject="Refund")
With LangGraph
from portcullis.adapters.langgraph import govern_all
governed_tools = govern_all(engine, tools) # drop into your graph unchanged
The adapter returns tools of the same shape (a BaseTool stays a BaseTool),
so an existing graph needs no other changes.
Demo
Run these from a clone of the repository — see From source.
python demo/run_cli.py # live decisions in the terminal
python demo/run_cli.py --auto-approve # non-interactive (for capture)
open demo/web/index.html # the visual decision console
The demo runs one refund-processing task whose six actions touch all four cells, with the stakes escalating as it goes: two reversible reads auto-execute, two writes in the mixed cells execute but are recorded, and two irreversible high-blast-radius actions pause for a human.
The middle pair is the one worth watching. write_audit_note cannot be undone
but touches a single record; retag_open_tickets is trivially undone but
writes across the whole queue. Neither is dangerous enough to stop the agent,
and neither should pass without leaving a trail — which is exactly what the
two mixed cells are for.
The same governance, inside a LangGraph graph
From a clone (see From source — the demo scripts ship with the repository, not with the package):
pip install -e ".[langgraph]"
python demo/run_langgraph.py # interactive approvals
python demo/run_langgraph.py --auto-approve # non-interactive (for capture)
python demo/run_langgraph.py --deny-all # show fail-closed denials
run_langgraph.py runs the same task through an actual LangGraph
StateGraph (planner → ToolNode → loop). The tools are ordinary LangChain
@tool tools; governance is added by wrapping them with govern_all, not by
editing the graph. A denied action raises ActionDenied, which ToolNode
surfaces to the agent as a real tool error. The planner is scripted so the
demo is deterministic and needs no API key — swap in create_react_agent
with an LLM and the governed tools drop in unchanged.
Design choices
- Core is dependency-free. Nothing in
portcullis.coreimports LangChain or any LLM SDK. The governance model is fully unit-tested without an API key — you can read and trust it on its own. - Fail-closed by default. An undeclared tool is treated as the most dangerous cell (irreversible + high), so unknown actions pause rather than slip through.
- Denials raise, they don't return sentinels. A blocked action raises
ActionDenied, so your agent framework sees a real, catchable failure. - Every decision is audited. Append-only JSONL: your answer to "why did the agent do that?"
Future roadmap: Not in v0.1
- LLM-assisted classification of reversibility/blast radius (v0.1: you declare it)
- Web / Slack approval handlers and multi-approver RBAC (the interface is ready)
- Persistence beyond a JSONL audit file
These are intentional cuts to keep the core small and legible. The interfaces
(ApprovalHandler, adapters) are shaped so each is an addition, not a rewrite.
Install
pip install portcullis # core only, zero dependencies
pip install "portcullis[langgraph]" # + LangGraph adapter
Requires Python 3.10+. The core has no dependencies at all, so the first line pulls in nothing but the package itself.
From source
The demo scripts live in the repository, not in the published package, so running them means cloning first:
git clone https://github.com/sameerbhatt/portcullis.git
cd portcullis
pip install -e ".[demo]" # everything needed to run the demo
pip install -e ".[dev]" # core + pytest, to run the suite
pytest -q
License
MIT.
Metadata
Release files for portcullis 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| portcullis-0.1.0.tar.gz | 15.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| portcullis-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 30.3 kB
Release files / portcullis-0.1.0.tar.gz
| Download URL | portcullis-0.1.0.tar.gz |
|---|---|
| Size | 15.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f1b8320521038a6167a81e3fb80352d31f7a93db0ff187aa465dc9262e352adc
|
|
BLAKE2b-256 checksum How to use checksums |
497aaa0e976ad8207254bfe1c6c1c5c443a1546a1eaa834c47b129d85e19073c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 23, 2026.
Transparency logRelease files / portcullis-0.1.0-py3-none-any.whl
| Download URL | portcullis-0.1.0-py3-none-any.whl |
|---|---|
| Size | 14.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f68860f1e5c781c20be79cec0957fe2d433acf44138d3882f9f7ceaa02cc4095
|
|
BLAKE2b-256 checksum How to use checksums |
00f68f4d753555a473df48eaa2b9d3beaabe27cb1c5abcb500d62dae3cbda780
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 23, 2026.
Transparency log