Chokepoint¶
Chokepoint expresses AI agent authorization policies as deterministic, code-defined predicates evaluated by the runtime — never as natural-language instructions in a prompt. Every tool call passes through the chokepoint (pre-hook and post-hook) before and after it executes. The LLM never sees, interprets, or reasons its way around a policy: policies are plain Python, evaluated outside the model's context.
Framework-agnostic — it's a middleware layer between an agent and its tools, not a full agent harness. The architecture guide explains the design rationale; the API reference has the signature-level detail.
Install¶
uv add chokepoint # or: pip install chokepoint
uv add "chokepoint[otel]" # with OpenTelemetry spans/metrics
uv add "chokepoint[langgraph]" # to wrap LangChain/LangGraph tools
uv add "chokepoint[openai-agents]" # to wrap OpenAI Agents SDK tools
uv add "chokepoint[mcp]" # to guard MCP tools/call, client or server side
uv add "chokepoint[all]" # every optional integration at once
Batteries included¶
chokepoint.policies ships the rules nearly every agent needs, so you don't
start from a blank lambda. Each returns an ordinary PolicySet and composes
with & / | / ~ like anything you'd write by hand:
from chokepoint import ChokepointInterceptor
from chokepoint.policies import (
budget_policy, domain_allowlist, no_destructive_shell,
no_secrets_in_args, path_within, rate_limit_policy, token_budget_policy,
)
interceptor = ChokepointInterceptor(policies=[
no_secrets_in_args(), # API keys, JWTs, PEM blocks
no_destructive_shell(tool_names=("run_shell",)), # rm -rf, mkfs, dd of=/dev/...
path_within(["/srv/workspace"], tool_names=("write_file",)),
domain_allowlist([".internal.corp"], tool_names=("http_get",)),
rate_limit_policy(50), # calls per session
budget_policy(1000.0, lambda ctx: ctx.args["amount"], tool_name="transfer"),
token_budget_policy(5.00, input_price=3.00, output_price=15.00,
tool_name="call_llm"), # dollars per session
])
Rate limits, budgets and token cost¶
Three shapes, because a limit is knowable at three different moments:
| Policy | Bounds | When the cost is known |
|---|---|---|
rate_limit_policy(n) |
number of calls per session | before the call |
budget_policy(max, amount_from=...) |
any figure derivable from the arguments | before the call — the cap is never exceeded |
token_budget_policy(max, input_price=…, output_price=…) |
dollars spent on model calls | only from the response — so the cap is stop once spent |
token_limit_policy(n) |
raw tokens per session | same as above |
An LLM call cannot be priced before it is made, so the call that crosses the
threshold completes and the next one is refused. That overshoot is inherent,
not a shortcut — budget_policy(max, amount_from=..., actual_from=...) bounds
it by charging an estimate up front and reconciling with the real figure
afterwards. Prices are per million tokens, the way providers quote them,
and no pricing table ships with the library: rates change, and a stale constant
baked into a security library would silently mis-bill.
token_budget_policy reads usage off the response and understands the
Anthropic (input_tokens), OpenAI (prompt_tokens) and Google
(promptTokenCount) shapes, as dicts or as SDK objects. A response with no
usage block contributes zero rather than failing closed. on_fail=ESCALATE is
often the better fit here — a human can decide whether this particular run is
worth more money.
Scope them with tool_names — an unscoped policy applies to every tool through
the same interceptor. The pattern matchers are seatbelts against agent
mistakes, not a sandbox: an adversary in full control of the input can evade
them.
Quickstart¶
from chokepoint import guard, BLOCK, ESCALATE
@guard(
pre=lambda ctx: ctx.state_checksum_matches(),
on_fail=BLOCK,
reason="state changed since planning",
)
def delete_record(record_id: str) -> dict:
...
@guard(
pre=lambda ctx: ctx.args["amount"] < 500,
on_fail=ESCALATE,
escalate_to="slack://finance-approvals",
timeout_s=300,
)
def transfer_funds(amount: float, to: str) -> dict:
...
A blocked or denied-escalation call raises chokepoint.GuardBlocked instead of
running. Guarded functions keep their original signature — call them
positionally or by keyword as usual — and ctx.args is always the flat
name → value mapping, mirroring how agent frameworks pass tool-call arguments
as a JSON object.
Every error Chokepoint raises on purpose derives from chokepoint.ChokepointError,
so one except clause catches the library's failures without swallowing your
own bugs:
try:
transfer_funds(amount=1000, to="alice")
except chokepoint.GuardBlocked as exc:
print(exc.decision.reason, exc.decision.policy_name)
except chokepoint.ChokepointError:
... # misconfiguration, escalation transport failure, ledger problem
Each one also subclasses the stdlib exception it replaced
(ConfigurationError is a ValueError, LedgerEventNotFound a KeyError),
so existing handlers keep working.
With nothing registered for the "slack" scheme, that ESCALATE above
always denies (fail-safe) — register a real handler to actually resolve it:
import os
from chokepoint import SlackEscalationHandler, register_handler
register_handler(
"slack",
SlackEscalationHandler(
bot_token=os.environ["SLACK_BOT_TOKEN"],
channel="C_FINANCE_APPROVALS",
approvers={"U_CFO", "U_CONTROLLER"}, # required — see SECURITY.md
),
)
WebhookEscalationHandler (one synchronous POST, expects
{"approved": bool} back) and CLIEscalationHandler (local
input()-based human-in-the-loop) are also built in — all three use only
urllib.request/stdlib, no new dependency. Run
uv run python examples/real_escalation_handlers.py for all three,
end to end.
Multi-agent: the same tool, different outcomes per caller¶
from chokepoint import ChokepointRegistry, ChokepointInterceptor, AgentScopedPolicy, BLOCK
registry = ChokepointRegistry()
registry.register("clinical_agent", role="licensed_physician")
registry.register("support_agent", role="support_staff")
delete_policy = AgentScopedPolicy(
name="only_physician_can_delete_patient",
allowed_roles=["licensed_physician"],
applies_to=lambda ctx: ctx.tool_name == "delete_patient",
on_fail=BLOCK,
reason="deleting a patient record is restricted to physicians",
)
clinical = ChokepointInterceptor(registry=registry, agent_id="clinical_agent", policies=[delete_policy])
support = ChokepointInterceptor(registry=registry, agent_id="support_agent", policies=[delete_policy])
clinical.call("delete_patient", delete_patient, id=1) # allowed
support.call("delete_patient", delete_patient, id=1) # GuardBlocked
call() takes the tool's arguments as keywords for brevity, but session_id
and domain are its own parameters. If your tool declares an argument by
either name, pass the arguments explicitly instead — Chokepoint warns rather than
silently dropping one:
interceptor.call("send", send, args={"domain": "example.com", "body": "hi"})
Tools wrapped with interceptor.use(agent) or wrap_tool() are immune: every
keyword they receive is forwarded as a tool argument.
Run the full worked example (registry, ReversibleAction, escalation, two
interceptors) with:
uv run python examples/clinical.py
Async tools¶
@guard and ChokepointInterceptor.acall() auto-detect an async def tool
function — no separate decorator or interceptor class needed:
@guard(pre=lambda ctx: ctx.args["amount"] < 500, on_fail=BLOCK, reason="too large")
async def transfer_funds(amount: float, to: str) -> dict:
return await payments_api.transfer(amount, to)
await transfer_funds(amount=100, to="alice") # await it, same as the undecorated function
Predicates stay synchronous (cheap, deterministic checks); ReversibleAction.do_fn/
undo_fn and a custom EscalationHandler.escalate may be def or async def.
Run the full worked example with uv run python examples/async_tool.py.
Framework integration¶
interceptor.use(agent) (or the module-level chokepoint.wrap(agent, interceptor))
auto-detects LangChain/LangGraph BaseTool objects and OpenAI Agents SDK
FunctionTool objects — no manual adapter registration needed:
# LangGraph / LangChain — wrap the tool list before building the graph
wrapped_tools = interceptor.use([transfer_funds, search_web])
create_react_agent(model, tools=wrapped_tools)
# OpenAI Agents SDK — wrap an Agent's .tools in place
agent = Agent(name="finance_agent", tools=[transfer_funds, search_web])
interceptor.use(agent)
MCP is auto-detected too, on either side of the protocol:
# Client side — police what your agent asks any server to do. A denial raises.
chokepoint.wrap(client_session, interceptor)
# Server side — the policy holds whoever connects. A denial returns
# CallToolResult(isError=True), because raising would kill the connection.
chokepoint.wrap(mcp_server, interceptor)
Works with mcp 1.x and 2.x alike — the adapter detects which registration API
the installed package exposes, so MCPServer (2.x), FastMCP (1.x) and a
low-level Server are all accepted, as is 2.x's Client facade on the client
side.
Requires the matching extra (chokepoint[langgraph] / chokepoint[openai-agents] /
chokepoint[mcp]). Run uv run python examples/langgraph_integration.py,
examples/openai_agents_integration.py, or examples/mcp_integration.py.
Reversible actions¶
from chokepoint import ReversibleAction
delete_s3_bucket = ReversibleAction(
do_fn=lambda args: s3.delete_bucket(args["bucket"]),
undo_fn=lambda args, snapshot: s3.restore_from_snapshot(snapshot),
name="delete_s3_bucket",
irreversibility_level="high", # auto-escalates before every execution
escalate_to="slack://infra-approvals",
pre_snapshot=lambda args: s3.snapshot(args["bucket"]),
)
irreversibility_level: "low" runs normally; "medium" requires undo_fn (raised
at construction if missing); "high" auto-escalates before every call; "permanent"
is an unconditional block — the action never runs.
A "high" action needs an escalate_to target to differ from "permanent":
with none, its escalation resolves to the fail-safe handler, which denies, so
every call is blocked. chokepoint lint warns about this.
Examples¶
Every file under examples/ is runnable directly (uv run python examples/<name>.py):
| File | Demonstrates |
|---|---|
quickstart.py |
The smallest possible setup — two @guard-decorated functions, BLOCK and ESCALATE. |
policy_composition.py |
& / \| / ~ — combining Policy objects into a deploy-pipeline gate. |
dry_run_rollout.py |
Rolling out a new policy safely: mode="dry_run" logs what would block, then flip to enforce. |
custom_escalation_handler.py |
A minimal, hand-rolled EscalationHandler (toy in-process approve/deny logic). |
real_escalation_handlers.py |
The three built-in real handlers — SlackEscalationHandler, WebhookEscalationHandler, CLIEscalationHandler — each demonstrated end to end. |
reversible_levels.py |
ReversibleAction's four irreversibility_levels side by side. |
delegation_chain.py |
Confused-deputy prevention via delegation-chain depth (max_delegation_depth_policy), not just role. |
clinical.py |
The full multi-agent story: ChokepointRegistry, AgentScopedPolicy, ReversibleAction, escalation, two interceptors sharing one tool. |
multi_agent_orchestrator.py |
A centralized registry with 4 agents, several tools, role + trust-level policies together, and an exported delegation graph. |
async_tool.py |
@guard and ChokepointInterceptor.acall() on async def tools. |
langgraph_integration.py |
Wrapping real langchain_core.tools.BaseTool objects for LangGraph (requires chokepoint[langgraph]). |
openai_agents_integration.py |
Wrapping real agents.FunctionTool objects for the OpenAI Agents SDK (requires chokepoint[openai-agents]). |
mcp_integration.py |
Guarding MCP tools/call from both the client and the server side (requires chokepoint[mcp]). |
builtin_policies.py |
Every policy in chokepoint.policies — secrets, destructive SQL/shell, path confinement, domain allowlist, rate limit, budget. |
redaction.py |
Keeping secrets and PII out of the ledger, the JSONL sink, the exports and the Slack message. |
audit_and_reporting.py |
ActionLedger's compliance/graph/narrative/pytest-fixture export methods. |
Audit trail¶
Every decision is recorded to a process-wide ActionLedger. In memory it's a
bounded ring buffer (10,000 events by default) — a memory bound, not a
durability story. For a full lossless history, point it at a JSONL file once at
startup, before the first guarded call:
import chokepoint
chokepoint.configure_ledger(sink_path="/var/log/chokepoint/decisions.jsonl")
Every event is mirrored to that file regardless of the in-memory cap, and
chokepoint report --ledger /var/log/chokepoint/decisions.jsonl reads it back. A
sink write that fails is logged and counted in ActionLedger.sink_error_count,
never raised: recording happens after the tool has already run, so a full disk
must not turn a call the policies allowed into an exception.
For several tenants in one process, give each interceptor its own ledger instead of configuring the global one:
interceptor = ChokepointInterceptor(policies=[...], ledger=ActionLedger(sink_path=...))
Redaction¶
Tool arguments reach the ledger, that JSONL file, the JSON/CSV exports and the
escalation message posted to Slack. Credential-shaped values are scrubbed
out of all of them by default, along with values under names like
password / api_key / authorization:
interceptor.call("call_api", call_api, authorization="Bearer AKIAIOSFODNN7EXAMPLE")
# ledger: {"authorization": "[REDACTED]"}
Policies still evaluate against the real arguments — redaction happens at record time, so a policy that inspects a credential can still see it. PII patterns (email, SSN, IBAN, Luhn-checked card numbers) are opt-in, because an email address is often the point of the call:
chokepoint.configure_redaction(include_pii=True, keys=["mrn", "dob"])
chokepoint.configure_redaction(enabled=False) # off entirely
chokepoint.configure_redaction(redactor=MyDLPClient()) # your own scrubber
One tradeoff: chokepoint.replay() reconstructs its context from the stored
event, so replaying a redacted call feeds placeholders to the predicates.
ReplayResult.redacted flags it, and export --format fixtures skips those
events instead of emitting tests that cannot pass. Run
uv run python examples/redaction.py for the whole picture.
CLI¶
uv run chokepoint --version
uv run chokepoint report --agent my_agent.py # policy inventory + coverage
uv run chokepoint report --agent my_agent.py --format mermaid # coverage graph
uv run chokepoint report --agent my_agent.py --delegation # delegation graph
uv run chokepoint report --agent my_agent.py --fail-under 0.8 # CI gate on tool coverage
uv run chokepoint lint --agent my_agent.py # static policy checks
uv run chokepoint replay evt_a3f9b2 # replay a ledger event
uv run chokepoint repl --agent my_agent.py # synthetic-context REPL
uv run chokepoint export --format narrative --ledger audit.jsonl # plain-English audit summary
uv run chokepoint export --format fixtures -o test_policies.py # pytest tests from real decisions
report --fail-under and lint both exit non-zero on failure, so they work as
CI gates. export accepts json, csv, narrative and fixtures.
The CLI loads my_agent.py as a plain module and expects a module-level
POLICIES: list[Policy] (and, optionally, REGISTRY / TOOL_NAMES /
ACTIONS) — see
examples/clinical.py.
Development¶
uv sync --extra all
uv run pytest -q
uv run ruff check .
uv run ruff format --check .
uv run mypy src/chokepoint
Commits follow Conventional Commits and
CI enforces it — see CONTRIBUTING.md.
Docs are published at pedromuracchini.github.io/chokepoint, assembled from this repository's markdown plus an API reference generated from the package's own docstrings:
uv run --group docs python scripts/build_docs.py
uv run --group docs mkdocs serve
The architecture guide covers the design rationale and what is deliberately out of scope.