Actenon-Permit: an open-source authority broker for AI agents. Bounded, revocable capability grants with hard runtime limits.
Project description
Actenon-Permit
uv run permit demo
Requires uv (
curl -LsSf https://astral.sh/uv/install.sh | sh). No uv?pip install -e '.[dev]' && permit demo, orpython -m actenon_permit.cli demo. No API keys, no network, no real money — works from a fresh clone.
Why
Most agent security incidents aren't clever exploits — they're over-permissioned agents doing permitted-but-catastrophic things (a refund bot that can also charge, an email helper with no spend cap, a coding agent whose shell tool has no rate limit). Actenon-Permit is the enforcement layer that sits between an agent and the real world: it issues bounded, expiring, revocable capability "grants", enforces hard runtime limits the agent physically cannot exceed, gates high-impact actions behind human approval, and keeps a tamper-evident audit trail.
The demo
uv run permit demo --auto-approve produces this exact 7-step arc (v0
in-process mode):
======================================================================
Actenon-Permit demo — refund-bot (scripted agent, no LLM, no network)
======================================================================
issued grant: id=grant_xxxxxxxxxxxxxxxx
agent: refund-bot
budget: USD 50 (remaining 50)
scopes.allow: ['payment.refund', 'email.send']
scopes.deny: ['payment.charge', 'shell.*']
approval: ['email.send']
step 1: refund($20) -> [ALLOW] allowed (budget 50 -> 30 ...)
step 2: refund($25) -> [ALLOW] allowed (budget 30 -> 5 ...)
step 3: refund($20) -> [DENY] would exceed USD 50 budget (only $5 left)
step 4: send_email(...) -> [ALLOW] approved by human (...)
step 5: charge($100) -> [DENY] scope denied: payment.charge (simulated injection)
>>> kill switch: `permit revoke refund-bot` — grant REVOKED
step 7: refund($1) -> [DENY] grant status is revoked (grant REVOKED)
ledger chain intact: True
proof the agent never held the real key:
the call signature the agent used was `refund(amount=20)` — no `secret` arg.
the broker resolved MOCK_STRIPE_KEY=sk_mock_*** internally and passed it
only to the mock provider. the agent only saw the allow/deny result.
======================================================================
demo complete.
======================================================================
v1 gateway demo (out-of-process PEP)
uv run permit demo --mode gateway --auto-approve runs the same 7-step arc,
but every action goes through the v1 out-of-process gateway over HTTP. The
agent process never imports the mock providers, never holds the secret, and
only talks to the gateway via POST /proxy/<tool> with a bearer token. This
is the real airlock: even an agent with arbitrary code-exec cannot import the
provider SDK to bypass the wrapper, because the secret is not in its memory.
How it works
- Grant — a signed, scoped, expiring capability issued to an agent out-of-band.
- Decide — every action runs through a deterministic, fail-closed PDP (status → expiry → deny-scope → allow-scope → rate → budget → approval).
- Broker — on ALLOW, the broker swaps the grant for the real credential for that one call; the secret never enters the agent's context.
- Ledger — every decision is appended to a hash-chained, tamper-evident log. The agent only ever sees the tool signature and the allow/deny result.
v0 vs v1 — both shipped
v0 = in-process cooperative enforcement. The PEP is a Python decorator
(@guard) inside the agent's process. This defeats the dominant threat — an
injected or confused agent choosing a dangerous tool or argument, runaway
loops, overspend — because the agent picks tool + args from a fixed registry
and the raw key only exists inside the broker. This is ~90% of real agent
incidents (Step Finance, Grok, Replit were all "agent did a permitted-but-
catastrophic thing").
v1 = out-of-process proxy / MCP-gateway PEP (now built). The gateway runs in a separate process, holds the real credentials, and enforces every decision server-side. The agent only ever holds a signed bearer token. Two transports are supported:
- HTTP proxy —
POST /proxy/{tool}withX-Actenon-Grantheader. Returns the tool's result on ALLOW, 403 with a JSON body on DENY. - MCP stdio — JSON-RPC 2.0 over stdin/stdout. Compatible with Claude
Desktop, Cursor, and any MCP-aware agent host. The grant token is passed
in
params._meta.actenon_grant.
Switch from v0 to v1 by changing one import in agent code:
# v0 (in-process)
from actenon_permit import guard, GuardRegistry
@guard("payment.refund", cost_from="amount",
credential_name="STRIPE_KEY", registry=reg)
def refund(secret, amount, reason=""): ... # agent holds the secret
# v1 (out-of-process — agent never holds the secret)
from actenon_permit import remote_guard, RemoteGuardRegistry
reg = RemoteGuardRegistry("http://127.0.0.1:7780")
reg.set_grant_token(token)
@remote_guard("payment.refund", cost_from="amount", registry=reg)
def refund(amount, reason=""): ... # no `secret` param — gateway holds it
v1 CLI
# ONE-TIME: persist a stable dev signing key so grants validate across
# process restarts (essential for multi-terminal workflows).
permit init-key
# Start the control plane + gateway on one port
permit serve --with-gateway --port 7780
# In another terminal: issue a grant + mint a token
permit issue examples/refund-bot-policy.yaml --quiet
permit mint-token <grant_id> --quiet
# Or run just the MCP stdio server (for Claude Desktop / Cursor)
permit mcp-serve
# Derive a weaker sub-grant (UCAN-style multi-agent delegation)
permit attenuate <grant_id> --budget-limit 10 --scopes-allow payment.refund
Without
permit init-key, eachpermitinvocation generates an ephemeral key, so a token minted bypermit mint-tokenin one process won't validate inpermit servein another.permit init-keywrites a stable key to~/.actenon-permit/signing-key(mode 0600) — run it once and forget.
TS SDK (ts-sdk/)
A typed TypeScript SDK for non-Python agents:
import { ControlPlaneClient, GatewayClient } from "@actenon/permit-sdk";
const cp = new ControlPlaneClient({ baseUrl: "http://127.0.0.1:7780" });
const grant = await cp.issueGrant({
agent: "refund-bot", ttl: "1h",
budget: { currency: "USD", limit: 50 },
scopes: { allow: ["payment.refund"], deny: ["payment.charge"] },
});
const { token } = await cp.mintToken(grant.id);
const gw = new GatewayClient({ baseUrl: "http://127.0.0.1:7780", grantToken: token });
const result = await gw.callTool("refund", { amount: 20, reason: "customer" });
Repo layout
actenon-permit/
├── README.md # this file — first code block is the working command
├── LICENSE # Apache-2.0
├── SPEC.md # the Grant / capability-token format (standards seed)
├── pyproject.toml # uv/pip installable; console entrypoint `permit`
├── .env.example # NAMES of secrets only — never real values
├── .gitignore # .env, *.db, __pycache__, .venv
├── .github/workflows/ci.yml # ruff + pytest + demo smoke on every push
├── src/actenon_permit/
│ ├── __init__.py # public API (v0 + v1)
│ ├── __main__.py # python -m actenon_permit
│ ├── model.py # Grant, Action, Decision, Scope, Budget, Rate
│ ├── policy.py # compile YAML/dict policy -> signed Grant
│ ├── pdp.py # decide(): deterministic, fail-closed
│ ├── state.py # StateStore iface + SQLiteStore (atomic reserve/commit)
│ ├── ledger.py # append-only hash-chained log
│ ├── broker.py # name->secret resolution; guarded execution
│ ├── enforce.py # @guard decorator + wrap (v0 in-process PEP)
│ ├── control.py # FastAPI control plane: grants/approvals/ledger/attenuate
│ ├── token.py # v1: grant bearer token wire format (v1.<b64url>)
│ ├── gateway.py # v1: out-of-process PEP gateway (ToolRegistry + Gateway)
│ ├── _proxy_routes.py # v1: HTTP proxy FastAPI routes (no future-annotations)
│ ├── pep_client.py # v1: remote_guard + RemoteGuardRegistry (agent side)
│ ├── _demo.py # v0 demo (in-process)
│ ├── _demo_gateway.py # v1 demo (out-of-process HTTP gateway)
│ ├── _mock_providers.py # fake payments + email — NO real network, NO real money
│ └── cli.py # permit issue|revoke|watch|ledger|demo|serve|mcp-serve|attenuate|mint-token
├── ts-sdk/ # v1: TypeScript SDK
│ ├── src/
│ │ ├── types.ts # Grant, Action, Decision, Policy, AttenuateRequest, ...
│ │ ├── token.ts # grant_to_token / verifyGrantToken (Web Crypto HMAC)
│ │ ├── client.ts # ControlPlaneClient (issue/revoke/attenuate/ledger)
│ │ ├── gateway-client.ts # GatewayClient (callTool via HTTP proxy)
│ │ └── index.ts # barrel export
│ ├── tests/sdk.test.ts # 6 tests incl. end-to-end against a real Python gateway
│ ├── package.json # @actenon/permit-sdk
│ └── tsconfig.json
├── examples/
│ ├── mock_providers.py # thin re-export wrapper (canonical impl in package)
│ └── demo.py # thin re-export wrapper
└── tests/
├── test_pdp.py # v0: allow/deny/budget/rate/expiry/scope/fail-closed
├── test_state.py # v0: concurrency — two parallel charges can't both clear $50
├── test_ledger.py # v0: tamper-evidence — editing an entry breaks the chain
├── test_demo.py # v0: full 7-step decision sequence end-to-end
├── test_token.py # v1: grant token roundtrip, tamper detection, wrong-key
├── test_gateway.py # v1: gateway direct + HTTP proxy + MCP stdio
├── test_remote_pep.py # v1: remote_guard end-to-end against a live HTTP server
└── test_attenuation_wire.py # v1: /grants/:id/attenuate + child token usable at gateway
Non-goals for v0/v1
No SaaS/multi-tenant, no distributed state, no real payment integration, no enterprise directory/SSO, no cloud, no Turing-complete policy DSL (declarative YAML only; OPA/Rego is a later escape hatch). Local, single-user, one unforgettable demo.
Roadmap (beyond v1)
- Distributed state — Raft/CRDT-backed state store for multi-node gateways. Currently the gateway is single-process; the state store is a single SQLite file.
- OPA/Rego policy escape hatch — a
policy_engine: "opa"option that delegatesdecide()to an embedded OPA server for Turing-complete rules. - Real provider SDKs — first-class adapters for Stripe, SendGrid, AWS,
etc. Currently the broker is provider-agnostic (just
real_call(secret, **args)). - MCP gateway-as-a-sidecar — a Docker image that runs
permit serve --with-gatewayso non-Python agents (Claude Desktop, Cursor, a Node.js agent) can mount it as a sidecar with zero local setup.
License
Apache-2.0. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file actenon_permit-1.4.0.tar.gz.
File metadata
- Download URL: actenon_permit-1.4.0.tar.gz
- Upload date:
- Size: 226.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2a48f8be1ed8dd3f747addfa0b8585c05eb8a8f12a6e97748cde45b5fd8dac61
|
|
| MD5 |
1633e127ee4b8a86c0edaaf9f6614039
|
|
| BLAKE2b-256 |
bc3e91aad55cb2bceb389b1c1c4371e5158cfda0b1fa7c88fa2636e9f9d52db8
|
File details
Details for the file actenon_permit-1.4.0-py3-none-any.whl.
File metadata
- Download URL: actenon_permit-1.4.0-py3-none-any.whl
- Upload date:
- Size: 143.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0c8610d586da74258f96855eabf382c6bd2eee1c5c94686cfefc14e10f1740e2
|
|
| MD5 |
3800e3a28a34b553fb070cc21c0a9a5c
|
|
| BLAKE2b-256 |
15a04fd4a615d8abda13d0242d70f16fd8cc0928dba8f79124fd3777a2590a73
|