actpass 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
actpass/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """ActPass Python SDK (spec section 14).
2
+
3
+ Zero-dependency client mirroring the TypeScript ``@actpass/sdk`` surface.
4
+
5
+ from actpass import create_actpass
6
+
7
+ client = create_actpass(api_key="...", tenant_id="1", agent_id="support_agent")
8
+ result = client.guard(
9
+ goal="resolve_refund_request",
10
+ tool="stripe.refund.create",
11
+ args={"chargeId": "ch_123", "amount": 14900, "currency": "USD"},
12
+ execute_fn=lambda ctx: do_refund(),
13
+ )
14
+ """
15
+
16
+ from .client import (
17
+ ActPassClient,
18
+ ActPassError,
19
+ create_actpass,
20
+ DEFAULT_BASE_URL,
21
+ )
22
+
23
+ __all__ = [
24
+ "ActPassClient",
25
+ "ActPassError",
26
+ "create_actpass",
27
+ "DEFAULT_BASE_URL",
28
+ ]
29
+
30
+ __version__ = "0.1.0"
actpass/client.py ADDED
@@ -0,0 +1,348 @@
1
+ """ActPass Python SDK client (spec section 14).
2
+
3
+ A zero-dependency client (Python standard library only) that mirrors the
4
+ TypeScript ``packages/sdk`` surface: an :class:`ActPassClient` with
5
+ ``preflight()``, ``guard()``, ``issue_passport()``, ``verify_passport()``,
6
+ ``revoke_passport()`` and ``record_evidence()``.
7
+
8
+ Design notes that mirror the TS client (``packages/sdk/src/client.ts``):
9
+
10
+ * HTTP transport is ``urllib.request`` + ``json`` from the stdlib so the SDK
11
+ carries **zero third-party dependencies**.
12
+ * Requests authenticate with a ``Bearer`` API key plus the
13
+ ``x-actpass-tenant`` header. The tenant is resolved server-side from the
14
+ authenticated principal (spec section 11.2) — it is sent only as a routing
15
+ hint, never trusted by the gateway.
16
+ * The client **fails closed**: any non-2xx response raises
17
+ :class:`ActPassError`, except ``401``/``403`` which are returned as the
18
+ parsed JSON body so callers can inspect the auth failure (matching the TS
19
+ ``post()`` behaviour). ``guard()`` therefore only executes the caller's
20
+ function when the decision is ``allow`` or ``warn``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import hmac
27
+ import json
28
+ import os
29
+ import urllib.error
30
+ import urllib.request
31
+ from typing import Any, Callable, Dict, List, Optional, TypeVar
32
+
33
+ __all__ = [
34
+ "ActPassClient",
35
+ "ActPassError",
36
+ "create_actpass",
37
+ "DEFAULT_BASE_URL",
38
+ ]
39
+
40
+ DEFAULT_BASE_URL = "https://api.actpass.org"
41
+
42
+ T = TypeVar("T")
43
+
44
+ # Decisions for which guard() proceeds to execute the caller's function.
45
+ _EXECUTABLE_DECISIONS = frozenset({"allow", "warn"})
46
+
47
+
48
+ class ActPassError(Exception):
49
+ """Raised on non-2xx responses (other than 401/403) or transport errors.
50
+
51
+ Failing closed is the whole point: callers should treat an
52
+ :class:`ActPassError` as "deny / do not execute".
53
+ """
54
+
55
+ def __init__(self, message: str, status: Optional[int] = None, body: Optional[str] = None):
56
+ super().__init__(message)
57
+ self.status = status
58
+ self.body = body
59
+
60
+
61
+ class ActPassClient:
62
+ """HTTP client for the ActPass public v1 endpoints.
63
+
64
+ Mirrors ``ActPassClient`` from ``packages/sdk/src/client.ts``.
65
+
66
+ Args:
67
+ api_key: Bearer API key (``<key>`` half of an ``ACTPASS_API_KEYS`` pair).
68
+ tenant_id: Tenant routing hint sent as ``x-actpass-tenant``. The server
69
+ resolves the authoritative tenant from the principal.
70
+ agent_id: Identifier injected into preflight/passport request bodies.
71
+ base_url: API base URL. Falls back to the ``ACTPASS_API_BASE_URL`` env
72
+ var, then to :data:`DEFAULT_BASE_URL`.
73
+ timeout: Per-request socket timeout in seconds.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ api_key: str,
79
+ tenant_id: Optional[str] = None,
80
+ agent_id: Optional[str] = None,
81
+ base_url: Optional[str] = None,
82
+ timeout: float = 30.0,
83
+ ) -> None:
84
+ self.api_key = api_key
85
+ # Identity is resolved server-side from the key (§11.2); these are hints.
86
+ self.tenant_id = tenant_id
87
+ self.agent_id = agent_id or "sdk"
88
+ self.base_url = (
89
+ base_url
90
+ or os.environ.get("ACTPASS_API_BASE_URL")
91
+ or DEFAULT_BASE_URL
92
+ ).rstrip("/")
93
+ self.timeout = timeout
94
+
95
+ # ------------------------------------------------------------------ #
96
+ # Low-level transport
97
+ # ------------------------------------------------------------------ #
98
+ def _headers(self, with_content_type: bool) -> Dict[str, str]:
99
+ headers = {"authorization": f"Bearer {self.api_key}"}
100
+ if self.tenant_id:
101
+ headers["x-actpass-tenant"] = self.tenant_id
102
+ if with_content_type:
103
+ headers["content-type"] = "application/json"
104
+ return headers
105
+
106
+ def _request(self, method: str, path: str, body: Optional[Any]) -> Dict[str, Any]:
107
+ url = f"{self.base_url}{path}"
108
+ data = None
109
+ if body is not None:
110
+ data = json.dumps(body).encode("utf-8")
111
+ req = urllib.request.Request(
112
+ url,
113
+ data=data,
114
+ method=method,
115
+ headers=self._headers(with_content_type=body is not None),
116
+ )
117
+ try:
118
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
119
+ return self._read_json(resp)
120
+ except urllib.error.HTTPError as exc:
121
+ # 401/403 carry an inspectable JSON body in the TS client; surface
122
+ # it the same way instead of raising. Everything else fails closed.
123
+ status = exc.code
124
+ raw = exc.read().decode("utf-8", "replace") if exc.fp else ""
125
+ if method == "POST" and status in (401, 403):
126
+ try:
127
+ return json.loads(raw) if raw else {}
128
+ except (ValueError, TypeError):
129
+ return {}
130
+ raise ActPassError(
131
+ f"ActPass {path} {status}: {raw}", status=status, body=raw
132
+ ) from exc
133
+ except urllib.error.URLError as exc:
134
+ # Network failure -> fail closed.
135
+ raise ActPassError(f"ActPass {path} unreachable: {exc.reason}") from exc
136
+
137
+ @staticmethod
138
+ def _read_json(resp: Any) -> Dict[str, Any]:
139
+ raw = resp.read().decode("utf-8", "replace")
140
+ if not raw:
141
+ return {}
142
+ try:
143
+ return json.loads(raw)
144
+ except (ValueError, TypeError) as exc:
145
+ raise ActPassError(f"ActPass returned non-JSON body: {raw}") from exc
146
+
147
+ def _post(self, path: str, body: Any) -> Dict[str, Any]:
148
+ return self._request("POST", path, body)
149
+
150
+ def _get(self, path: str) -> Dict[str, Any]:
151
+ return self._request("GET", path, None)
152
+
153
+ # ------------------------------------------------------------------ #
154
+ # Public API surface (mirrors packages/sdk/src/client.ts)
155
+ # ------------------------------------------------------------------ #
156
+ def preflight(
157
+ self,
158
+ goal: str,
159
+ tool: str,
160
+ args: Dict[str, Any],
161
+ resource: str = "",
162
+ user_id: Optional[str] = None,
163
+ passport: Optional[str] = None,
164
+ mode: str = "enforce",
165
+ idempotency_key: Optional[str] = None,
166
+ audience: Optional[str] = None,
167
+ ) -> Dict[str, Any]:
168
+ """Evaluate the deterministic policy for a tool execution.
169
+
170
+ Returns the raw preflight decision dict (``decision``, ``reason_code``,
171
+ ``risk_tier``, ``explain``, ...).
172
+ """
173
+ return self._post(
174
+ "/api/v1/actions/preflight",
175
+ {
176
+ "agent_id": self.agent_id,
177
+ "goal": goal,
178
+ "tool": tool,
179
+ "resource": resource,
180
+ "args": args,
181
+ "user_id": user_id,
182
+ "passport": passport,
183
+ "mode": mode,
184
+ "idempotency_key": idempotency_key,
185
+ "audience": audience,
186
+ },
187
+ )
188
+
189
+ def guard(
190
+ self,
191
+ goal: str,
192
+ tool: str,
193
+ args: Dict[str, Any],
194
+ execute_fn: Callable[[Dict[str, Any]], T],
195
+ resource: str = "",
196
+ user_id: Optional[str] = None,
197
+ passport: Optional[str] = None,
198
+ mode: str = "enforce",
199
+ idempotency_key: Optional[str] = None,
200
+ audience: Optional[str] = None,
201
+ ) -> Dict[str, Any]:
202
+ """Preflight + (optionally) execute in one call.
203
+
204
+ ``execute_fn`` is invoked with a ``{"credential": {...}}`` context only
205
+ when the decision is ``allow`` or ``warn``. Returns a dict shaped like
206
+ the TS ``GuardOutput``: ``{"decision": ..., "result"?: ..., "error"?: ...}``.
207
+ """
208
+ decision = self.preflight(
209
+ goal=goal,
210
+ tool=tool,
211
+ args=args,
212
+ resource=resource,
213
+ user_id=user_id,
214
+ passport=passport,
215
+ mode=mode,
216
+ idempotency_key=idempotency_key,
217
+ audience=audience,
218
+ )
219
+ if decision.get("decision") not in _EXECUTABLE_DECISIONS:
220
+ return {"decision": decision}
221
+ ctx = {"credential": {"token": "sdk-local", "expires_at": None}}
222
+ try:
223
+ result = execute_fn(ctx)
224
+ return {"decision": decision, "result": result}
225
+ except Exception as err: # noqa: BLE001 - surface as GuardOutput.error
226
+ return {"decision": decision, "error": str(err)}
227
+
228
+ def issue_passport(
229
+ self,
230
+ audience: str,
231
+ goal: str,
232
+ allowed_tools: List[str],
233
+ allowed_resources: Optional[List[str]] = None,
234
+ resource_constraints: Optional[Dict[str, Any]] = None,
235
+ user_id: Optional[str] = None,
236
+ ttl_seconds: Optional[int] = None,
237
+ risk_tier: str = "medium",
238
+ ) -> Dict[str, Any]:
239
+ """Mint a scoped action passport (returns ``{token, jwks_uri}``)."""
240
+ return self._post(
241
+ "/api/v1/passports/issue",
242
+ {
243
+ "audience": audience,
244
+ "agent_id": self.agent_id,
245
+ "goal": goal,
246
+ "allowed_tools": allowed_tools,
247
+ "allowed_resources": allowed_resources or [],
248
+ "resource_constraints": resource_constraints or {},
249
+ "user_id": user_id,
250
+ "risk_tier": risk_tier,
251
+ "ttl_seconds": ttl_seconds,
252
+ },
253
+ )
254
+
255
+ def verify_passport(
256
+ self,
257
+ token: str,
258
+ audience: str,
259
+ agent_id: Optional[str] = None,
260
+ user_id: Optional[str] = None,
261
+ current_tool_manifest_hash: Optional[str] = None,
262
+ policy_hash: Optional[str] = None,
263
+ ) -> Dict[str, Any]:
264
+ """Verify a passport (returns ``{valid, errors}``)."""
265
+ return self._post(
266
+ "/api/v1/passports/verify",
267
+ {
268
+ "token": token,
269
+ "audience": audience,
270
+ "agent_id": agent_id,
271
+ "user_id": user_id,
272
+ "current_tool_manifest_hash": current_tool_manifest_hash,
273
+ "policy_hash": policy_hash,
274
+ },
275
+ )
276
+
277
+ def revoke_passport(self, jti: str, reason: Optional[str] = None) -> Dict[str, Any]:
278
+ """Revoke a passport by its ``jti`` (returns ``{revoked}``)."""
279
+ return self._post("/api/v1/passports/revoke", {"jti": jti, "reason": reason})
280
+
281
+ def record_evidence(
282
+ self,
283
+ event_type: str,
284
+ chain_id: str,
285
+ tool_id: Optional[str] = None,
286
+ decision: Optional[str] = None,
287
+ reason_code: Optional[str] = None,
288
+ metadata: Optional[Dict[str, Any]] = None,
289
+ request_hash: Optional[str] = None,
290
+ response_hash: Optional[str] = None,
291
+ ) -> Dict[str, Any]:
292
+ """Append an event to a tamper-evident evidence chain.
293
+
294
+ Returns ``{event_id, current_event_hash}``.
295
+ """
296
+ return self._post(
297
+ "/api/v1/evidence/events",
298
+ {
299
+ "event_type": event_type,
300
+ "chain_id": chain_id,
301
+ "tool_id": tool_id,
302
+ "decision": decision,
303
+ "reason_code": reason_code,
304
+ "metadata": metadata,
305
+ "request_hash": request_hash,
306
+ "response_hash": response_hash,
307
+ },
308
+ )
309
+
310
+ # ------------------------------------------------------------------ #
311
+ # Local helpers (stdlib hashing — no network, mirror TS canonical hashing)
312
+ # ------------------------------------------------------------------ #
313
+ @staticmethod
314
+ def hash_payload(payload: Any) -> str:
315
+ """SHA-256 of a canonical (sorted-key) JSON encoding of ``payload``.
316
+
317
+ Useful for ``request_hash`` / ``response_hash`` on evidence events.
318
+ """
319
+ canonical = json.dumps(
320
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
321
+ ).encode("utf-8")
322
+ return hashlib.sha256(canonical).hexdigest()
323
+
324
+ @staticmethod
325
+ def sign_payload(secret: str, payload: Any) -> str:
326
+ """HMAC-SHA256 hex signature over a canonical JSON encoding."""
327
+ canonical = json.dumps(
328
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
329
+ ).encode("utf-8")
330
+ return hmac.new(secret.encode("utf-8"), canonical, hashlib.sha256).hexdigest()
331
+
332
+
333
+ def create_actpass(
334
+ api_key: str,
335
+ tenant_id: Optional[str] = None,
336
+ agent_id: Optional[str] = None,
337
+ base_url: Optional[str] = None,
338
+ ) -> ActPassClient:
339
+ """Convenience factory mirroring the TS ``createActPass`` (spec section 14.2).
340
+
341
+ Only ``api_key`` is required: the server resolves tenant + agent from it.
342
+ """
343
+ return ActPassClient(
344
+ api_key=api_key,
345
+ tenant_id=tenant_id,
346
+ agent_id=agent_id,
347
+ base_url=base_url,
348
+ )
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: actpass
3
+ Version: 1.1.0
4
+ Summary: ActPass Python SDK — signed action authorization for AI agents (zero dependencies)
5
+ Project-URL: Homepage, https://app.actpass.org
6
+ Author: ActPass
7
+ License: MIT
8
+ Keywords: agents,ai,authorization,mcp,security
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Security
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+
15
+ # ActPass Python SDK
16
+
17
+ Zero-dependency (stdlib-only) Python client for the ActPass runtime, mirroring
18
+ the TypeScript `@actpass/sdk` surface (spec §14).
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install actpass
24
+ ```
25
+
26
+ ## Usage (§14.2)
27
+
28
+ ```python
29
+ from actpass import create_actpass
30
+
31
+ # The API key IS your identity: the server resolves tenant + agent from it.
32
+ client = create_actpass(api_key="sk_...")
33
+
34
+ # Preflight + guarded execution in one call. execute_fn runs ONLY on allow/warn.
35
+ result = client.guard(
36
+ goal="resolve_refund_request",
37
+ tool="stripe.refund.create",
38
+ resource="stripe:charge:ch_123",
39
+ args={"chargeId": "ch_123", "amount": 14900, "currency": "USD"},
40
+ execute_fn=lambda ctx: stripe.Refund.create(charge="ch_123", amount=14900),
41
+ )
42
+ print(result["decision"]["decision"]) # allow | deny | require_approval | ...
43
+ ```
44
+
45
+ Other methods: `preflight()`, `issue_passport()`, `verify_passport()`,
46
+ `revoke_passport()`, `record_evidence()`, plus `hash_payload()` /
47
+ `sign_payload()` helpers for evidence hashing.
48
+
49
+ ## Behavior
50
+ - **Fails closed:** any non-2xx (except 401/403, which return the parsed body)
51
+ raises `ActPassError`; a network failure raises too. `guard()` only executes
52
+ your function on `allow`/`warn`.
53
+ - **Tenant is a hint:** sent as `x-actpass-tenant`; the gateway resolves the
54
+ authoritative tenant from the authenticated principal (§11.2).
55
+ - **Zero dependencies:** `urllib`/`json`/`hmac`/`hashlib` from the stdlib only.
56
+
57
+ ## Test
58
+
59
+ ```bash
60
+ cd packages/sdk-python && python -m unittest discover -s tests
61
+ ```
@@ -0,0 +1,5 @@
1
+ actpass/__init__.py,sha256=hohXmKfVGOP0TBUnHWPkruaNMzEDujICT93ZDbnynA8,700
2
+ actpass/client.py,sha256=Zd7xMKGHAKE1BF7h9v7OuToFsviZ3E83YNvlohkBQOM,12752
3
+ actpass-1.1.0.dist-info/METADATA,sha256=Y8XIFzLhEf6TLBAaajtAd25dSKopw3msajgJv9jfLao,1986
4
+ actpass-1.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ actpass-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any