Metadata-Version: 2.5
Name: regent-control
Version: 0.3.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: hermes
Requires-Dist: pyyaml>=6; extra == 'hermes'
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"
```

## Coding agents — `regent-control hook` (Claude Code, Codex, Hermes)

Claude Code runs shell hooks around every tool call. `regent-control hook` is those hooks: the
money tool calls your agent makes (`mcp__stripe__*`, `mcp__get4agent__buy`, a `curl` to a
payment API…) go through the Regent gate *before* they run, and every completed one leaves a
signed receipt you can verify offline.

```bash
pip install regent-control
regent-control hook install claude-code --agent-id agt_… --key-file ~/.regent/control-key.txt
regent-control hook install codex          # same hooks, ~/.codex/hooks.json
regent-control hook install hermes         # pre_tool_call / post_tool_call shell hooks in ~/.hermes/config.yaml (needs pyyaml)
regent-control hook status [--target codex|hermes]
```

One rule file, one gate, four agents: Claude Code and Codex speak the same hook JSON; Hermes
sends `{tool_name, args, task_id}` and reads `{"decision": "block", "reason"}`, which
`--format hermes` translates (no context channel there, so observe-mode verdicts are log-only);
OpenClaw uses the `@regent-protocol/openclaw-regent` plugin instead of a shell hook.

That writes three hooks into `~/.claude/settings.json` (`PreToolUse`, `PostToolUse`,
`PostToolUseFailure`, matcher `mcp__.*`) and a rule file at `~/.regent/hook.json`:

```json
{
  "mode": "observe",
  "agent_id": "agt_…",
  "mandate_id": "man_…",
  "rules": [
    {"match": "^mcp__stripe__.*", "amount": "amount", "divisor": 100, "currency": "currency", "payee": "customer"},
    {"match": "^mcp__get4agent__buy$", "amount": ["price", "amount"], "payee": "seller"},
    {"match": "^Bash$", "input_match": {"command": "api\\.stripe\\.com"}, "tool": "stripe", "action": "charge",
     "amount": "amount_cents", "divisor": 100, "currency": "const:USD"}
  ]
}
```

* A tool call that matches no rule never touches the network.
* **observe** (default): the gate decides and records, nothing is blocked; the verdict is shown to
  the agent as context. **enforce**: a deny or an escalation blocks the call with the gate's reason,
  and an unreachable gate fails closed (`fail_open: true` to change that).
* An allow never bypasses Claude Code's own permission prompt — the gate adds a mandate check and
  evidence on top of the user's consent, it does not replace it.
* `PostToolUse` closes the decision and saves the receipt to `~/.regent/receipts/<decision_id>.jwt`;
  verify it with `regent-verify` (`pip install regent-receipt-verify`).
* `regent-control hook uninstall` removes the hooks; the key is read from `REGENT_CONTROL_KEY` or
  the `--key-file`, never written into settings.json.

The same gate, from OpenClaw: the `@regent-protocol/openclaw-regent` plugin registers a
`before_tool_call` hook with the identical rules (see `plugins/openclaw-regent` in this repo).

## 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.
