actpass 1.1.0__tar.gz

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.
@@ -0,0 +1,83 @@
1
+
2
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
3
+
4
+ # Local admin/ops scripts — powerful (reset any password, mint any key, change
5
+ # any plan). Intentionally kept out of version control.
6
+ scripts/reset-password.ts
7
+ scripts/mint-device-key.ts
8
+ scripts/set-plan.ts
9
+ scripts/create-policy.ts
10
+ scripts/stress-test.ts
11
+
12
+ # dependencies
13
+ node_modules/
14
+ /.pnp
15
+ .pnp.js
16
+ .yarn/install-state.gz
17
+
18
+ # testing
19
+ /coverage
20
+
21
+ # next.js
22
+ /.next/
23
+ /out/
24
+
25
+ # production
26
+ /build
27
+
28
+ # misc
29
+ .DS_Store
30
+ *.pem
31
+
32
+ # debug
33
+ npm-debug.log*
34
+ yarn-debug.log*
35
+ yarn-error.log*
36
+
37
+ # local env files
38
+ .env
39
+
40
+ # vercel
41
+ .vercel
42
+
43
+ # typescript
44
+ *.tsbuildinfo
45
+ next-env.d.ts
46
+ .vscode
47
+
48
+ # Docker
49
+ postgres_data/
50
+ .env*.local
51
+
52
+ # local test files
53
+ test-keys.mjs
54
+ sqlite.db
55
+ supabase-jwks.json
56
+
57
+ # Playwright
58
+ /test-results/
59
+ /playwright-report/
60
+ /blob-report/
61
+ /playwright/.cache/
62
+
63
+ # E2E suite outputs (regenerable: screenshots, HTML report, run artifacts, auth session)
64
+ /tests/e2e/__screenshots__/
65
+ /tests/e2e/__report__/
66
+ /tests/e2e/__results__/
67
+ /tests/e2e/.auth/
68
+
69
+ # Local agent/tooling caches (not part of the project)
70
+ .claude/
71
+ .codesight/
72
+ .serena/
73
+
74
+ # Compiled CLI/SDK output (regenerated by tsc)
75
+ packages/*/dist/
76
+
77
+ # Generated demo media (~500MB of webm/mp4/wav/png) — regenerated by the demo
78
+ # scripts, not source. Keep out of history.
79
+ scripts/demo-video/
80
+
81
+ # Browser-tooling scratch from agent sessions (not part of the project)
82
+ .playwright-mcp/
83
+ scripts/demo/.demo-state
actpass-1.1.0/PKG-INFO ADDED
@@ -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,47 @@
1
+ # ActPass Python SDK
2
+
3
+ Zero-dependency (stdlib-only) Python client for the ActPass runtime, mirroring
4
+ the TypeScript `@actpass/sdk` surface (spec §14).
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install actpass
10
+ ```
11
+
12
+ ## Usage (§14.2)
13
+
14
+ ```python
15
+ from actpass import create_actpass
16
+
17
+ # The API key IS your identity: the server resolves tenant + agent from it.
18
+ client = create_actpass(api_key="sk_...")
19
+
20
+ # Preflight + guarded execution in one call. execute_fn runs ONLY on allow/warn.
21
+ result = client.guard(
22
+ goal="resolve_refund_request",
23
+ tool="stripe.refund.create",
24
+ resource="stripe:charge:ch_123",
25
+ args={"chargeId": "ch_123", "amount": 14900, "currency": "USD"},
26
+ execute_fn=lambda ctx: stripe.Refund.create(charge="ch_123", amount=14900),
27
+ )
28
+ print(result["decision"]["decision"]) # allow | deny | require_approval | ...
29
+ ```
30
+
31
+ Other methods: `preflight()`, `issue_passport()`, `verify_passport()`,
32
+ `revoke_passport()`, `record_evidence()`, plus `hash_payload()` /
33
+ `sign_payload()` helpers for evidence hashing.
34
+
35
+ ## Behavior
36
+ - **Fails closed:** any non-2xx (except 401/403, which return the parsed body)
37
+ raises `ActPassError`; a network failure raises too. `guard()` only executes
38
+ your function on `allow`/`warn`.
39
+ - **Tenant is a hint:** sent as `x-actpass-tenant`; the gateway resolves the
40
+ authoritative tenant from the authenticated principal (§11.2).
41
+ - **Zero dependencies:** `urllib`/`json`/`hmac`/`hashlib` from the stdlib only.
42
+
43
+ ## Test
44
+
45
+ ```bash
46
+ cd packages/sdk-python && python -m unittest discover -s tests
47
+ ```
@@ -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"
@@ -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,25 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "actpass"
7
+ version = "1.1.0"
8
+ description = "ActPass Python SDK — signed action authorization for AI agents (zero dependencies)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "ActPass" }]
13
+ keywords = ["ai", "agents", "security", "authorization", "mcp"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Topic :: Security",
18
+ ]
19
+ dependencies = []
20
+
21
+ [project.urls]
22
+ Homepage = "https://app.actpass.org"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["actpass"]
@@ -0,0 +1,85 @@
1
+ """Unit tests for the ActPass Python SDK (stdlib unittest, HTTP mocked).
2
+
3
+ Run: python -m unittest discover -s tests
4
+ """
5
+ import json
6
+ import sys
7
+ import unittest
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ # Make the package importable when running from this directory.
12
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
13
+
14
+ from actpass import ActPassClient, ActPassError, create_actpass, DEFAULT_BASE_URL # noqa: E402
15
+
16
+
17
+ def _client():
18
+ return create_actpass(api_key="k", tenant_id="1", agent_id="support_agent",
19
+ base_url="https://api.test")
20
+
21
+
22
+ class TestActPassClient(unittest.TestCase):
23
+ def test_preflight_posts_expected_body(self):
24
+ c = _client()
25
+ captured = {}
26
+
27
+ def fake_request(method, path, body):
28
+ captured["method"] = method
29
+ captured["path"] = path
30
+ captured["body"] = body
31
+ return {"decision": "allow", "reason_code": "ok"}
32
+
33
+ with mock.patch.object(ActPassClient, "_request", side_effect=fake_request):
34
+ res = c.preflight(goal="g", tool="stripe.refund.create", args={"amount": 100})
35
+
36
+ self.assertEqual(captured["method"], "POST")
37
+ self.assertEqual(captured["path"], "/api/v1/actions/preflight")
38
+ self.assertEqual(captured["body"]["tool"], "stripe.refund.create")
39
+ self.assertEqual(captured["body"]["agent_id"], "support_agent")
40
+ self.assertEqual(res["decision"], "allow")
41
+
42
+ def test_guard_executes_only_on_allow(self):
43
+ c = _client()
44
+ with mock.patch.object(ActPassClient, "preflight", return_value={"decision": "allow"}):
45
+ out = c.guard(goal="g", tool="t", args={}, execute_fn=lambda ctx: "done")
46
+ self.assertEqual(out["result"], "done")
47
+
48
+ def test_guard_blocks_on_deny(self):
49
+ c = _client()
50
+ calls = []
51
+ with mock.patch.object(ActPassClient, "preflight", return_value={"decision": "deny"}):
52
+ out = c.guard(goal="g", tool="t", args={}, execute_fn=lambda ctx: calls.append(1))
53
+ self.assertNotIn("result", out)
54
+ self.assertEqual(calls, []) # execute_fn never ran
55
+
56
+ def test_hash_payload_is_canonical(self):
57
+ a = ActPassClient.hash_payload({"b": 1, "a": 2})
58
+ b = ActPassClient.hash_payload({"a": 2, "b": 1})
59
+ self.assertEqual(a, b) # key order independent
60
+ self.assertEqual(len(a), 64) # sha256 hex
61
+
62
+ def test_sign_payload_deterministic(self):
63
+ s1 = ActPassClient.sign_payload("secret", {"x": 1})
64
+ s2 = ActPassClient.sign_payload("secret", {"x": 1})
65
+ self.assertEqual(s1, s2)
66
+ self.assertNotEqual(s1, ActPassClient.sign_payload("other", {"x": 1}))
67
+
68
+
69
+ class TestDistributionContract(unittest.TestCase):
70
+ def test_default_base_url_is_canonical(self):
71
+ # D1: exactly one canonical host, and the client defaults to it.
72
+ self.assertEqual(DEFAULT_BASE_URL, "https://api.actpass.org")
73
+ self.assertEqual(ActPassClient(api_key="x").base_url, DEFAULT_BASE_URL)
74
+
75
+ def test_api_key_only_construction(self):
76
+ # D2: no tenant_id / agent_id required; agent_id falls back to 'sdk'.
77
+ c = create_actpass(api_key="x")
78
+ self.assertEqual(c.agent_id, "sdk")
79
+ self.assertIsNone(c.tenant_id)
80
+ # tenant header omitted when there is no tenant_id.
81
+ self.assertNotIn("x-actpass-tenant", c._headers(with_content_type=False))
82
+
83
+
84
+ if __name__ == "__main__":
85
+ unittest.main()