Metadata-Version: 2.5
Name: regent-control
Version: 0.1.0
Summary: Python SDK for Regent Control — runtime policy enforcement for AI agent actions (allow / deny / escalate, scoped tokens, delegation, idempotency).
Project-URL: Homepage, https://regentprotocol.org
Project-URL: Documentation, https://docs.regentprotocol.org/control
Project-URL: Changelog, https://docs.regentprotocol.org/control/integration
Author: Regent Protocol
License: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Keywords: agents,ai,authorization,cedar,guardrails,mcp,policy
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: pyjwt[crypto]>=2.8; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Provides-Extra: verify
Requires-Dist: pyjwt[crypto]>=2.8; extra == 'verify'
Description-Content-Type: text/markdown

# Regent Control — Python SDK

Runtime policy enforcement for AI agent actions. Every action your agent takes —
a refund, a wire, a DB write, an API call — is authorized first: **allow / deny /
escalate**, with a short-lived scoped token, the human it's acting for, the intent,
and an immutable audit record.

```bash
pip install regent-control
```

Requires Python 3.10+. The only dependency is `httpx`.

## Two ways to integrate

### 1. Gate-direct — `RegentControl` (you hold the provider key)

Authorize the action, get a scoped token, perform it, report the outcome.

```python
from regent_control import RegentControl, MandateExceeded, Escalated

control = RegentControl(api_key="rgnt_ctrl_…", agent_id="agent_refund_bot")

decision = control.authorize(
    tool="payments", action="refund.create",
    amount_usd=50, mandate_id="mnd_support_refunds",
    idempotency_key="T-8842:ch_aaa",          # a retry won't double-refund
    user_token=rep_id_token,                  # the rep's OIDC token — verified by the gate
    intent="refund the duplicate charge on ticket 8842",
    facts={"account_status": "active", "refund_to_original": True},
)

if not decision.allowed:
    ...                                        # decision.code / decision.reason
do_refund(scoped_token=decision.token)         # the token authorizes the downstream call
control.complete(decision.decision_id, status="success", downstream_ref=refund_id)
```

A `deny` is a **normal return value**, not an exception. Opt into raising:

```python
decision.raise_for_status()                    # raises MandateExceeded / PolicyDenied / Escalated / …
# or
control.authorize_or_raise(tool="payments", action="refund.create", amount_usd=999)
```

### 2. Sidecar-routing — `SidecarSession` (the agent holds **no** key)

Point your normal HTTP call at the sidecar; it injects the real vaulted credential
and forwards. A policy deny comes back as a typed error.

```python
from regent_control import SidecarSession, MandateExceeded

sc = SidecarSession(base_url="http://localhost:8080",
                    user_token=rep_id_token, intent="refund duplicate charge")
try:
    resp = sc.call("payments", "refunds", method="POST",
                   json={"charge": "ch_aaa", "amount": 50},
                   facts={"account_status": "active", "refund_to_original": True},
                   idempotency_key="T-8842:ch_aaa")
    refund = resp.json()
except MandateExceeded as e:
    ...                                        # e.code, e.reason, e.decision_id
```

Both clients have async twins: `AsyncRegentControl` and `AsyncSidecarSession`.

### Wrap a tool in one line — `@guarded`

```python
from regent_control import RegentControl, guarded

control = RegentControl(api_key="rgnt_ctrl_…", agent_id="agent_refund_bot")

@guarded(control, tool="payments", action="refund.create",
         amount_arg="amount_usd", mandate_id="mnd_support_refunds")
def issue_refund(*, charge: str, amount_usd: float, scoped_token: str = "") -> str:
    return provider.refund(charge, amount_usd, token=scoped_token)

issue_refund(charge="ch_1", amount_usd=50)     # authorized → runs → completed
issue_refund(charge="ch_1", amount_usd=999)    # raises MandateExceeded; never runs
```

The decorator authorizes before the call, injects the scoped token (if the function
declares `scoped_token`), and reports `success`/`failed` to close the audit. Pass
per-call context via a `control_context={...}` keyword.

## Verify the scoped token at your service edge — `regent_control.verify`

The complement to the client: your *downstream* service validates the gate-issued
scoped JWT against Regent's JWKS and enforces the scope itself (least privilege at the
edge — no shared static key, no trusting the caller).

```bash
pip install "regent-control[verify]"   # adds PyJWT
```

```python
from regent_control.verify import TokenVerifier, ScopeError

verifier = TokenVerifier(jwks_url="https://control-api.regentprotocol.org/v1/control/.well-known/jwks.json")
scoped = verifier.verify(token, expected_tool="payments", expected_action="refund.create")
# raises TokenError (bad sig / expired / wrong issuer|audience) or ScopeError (wrong scope)
# scoped.agent_id / scoped.decision_id are audit-ready
```

See [`examples/verify_service.py`](examples/verify_service.py) for a FastAPI dependency.

## Develop locally with zero prod — `regent-control dev`

```bash
regent-control dev --port 8009 --deny-over 50 --escalate-over 500
# point any client at  base_url="http://localhost:8009"
```

In tests, force a verdict with the in-process mock:

```python
from regent_control import RegentControl, MandateExceeded
from regent_control.dev import run_mock_control

def test_over_limit_is_denied():
    with run_mock_control(deny_over=50) as base_url:
        control = RegentControl(api_key="dev", agent_id="a", base_url=base_url)
        d = control.authorize("stripe", "refund.create", amount_usd=999)
        assert d.code == "MANDATE_LIMIT_EXCEEDED"
```

## Typed errors

| Exception | Decision code | Meaning |
|---|---|---|
| `IdentityNotResolved` | `IDENTITY_NOT_RESOLVED` | the agent id didn't resolve |
| `AgentNotActive` | `AGENT_NOT_ACTIVE` | the agent is suspended/revoked |
| `PolicyDenied` | `POLICY_DENIED` | a policy rule forbade it |
| `ToolNotAllowed` | `TOOL_NOT_ALLOWED` | the tool isn't in the agent's catalog |
| `MandateNotFound` | `MANDATE_NOT_FOUND` | a money action with no mandate |
| `MandateExceeded` | `MANDATE_LIMIT_EXCEEDED` | over the spend cap |
| `RiskThresholdExceeded` | `RISK_THRESHOLD_EXCEEDED` | risk score too high |
| `VelocityExceeded` | `VELOCITY_EXCEEDED` | too many decisions for this agent in the window; back off |
| `DuplicateRequest` | `DUPLICATE_REQUEST` | an identical money request was just allowed; pass an `idempotency_key` for a deliberate repeat |
| `Escalated` | `ESCALATION_REQUIRED` | needs a human approval (carries `.escalation`) |

`ControlError` (transport/auth/5xx) and `ControlNetworkError` (never reached the
plane) are always raised; everything above subclasses `ControlDenied`.

## Request signing (optional)

Pass `sign_requests=True` to HMAC-sign the request body with the per-agent secret
`HMAC-SHA256(api_key, agent_id)` (`X-Agent-Signature`). Identical to
`@regent/control-sdk` (TypeScript), so an agent moves between the two unchanged.

## Examples

- [`examples/quickstart.py`](examples/quickstart.py) — gate one action.
- [`examples/refund_agent.py`](examples/refund_agent.py) — the full bank refund agent
  (allow / deny / escalate), runnable with no setup.
