qerexa 0.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,4 @@
1
+ __pycache__/
2
+ .pytest_cache/
3
+ .ruff_cache/
4
+ *.py[cod]
qerexa-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Qerexa Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
qerexa-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: qerexa
3
+ Version: 0.1.0
4
+ Summary: Qerexa clients for verifiable credentials and fail-closed gate checks (preview).
5
+ Project-URL: Homepage, https://qerexa.com
6
+ Project-URL: Documentation, https://qerexa.com/docs
7
+ Project-URL: Repository, https://github.com/rodriguew/Trust-Rails/tree/main/packages/sdk-python
8
+ Project-URL: Issues, https://github.com/rodriguew/Trust-Rails/issues
9
+ Author: Qerexa
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai-agents,autogen,governance,trust,verifiable-credentials
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: requests<3,>=2.28
26
+ Provides-Extra: dev
27
+ Requires-Dist: build<2,>=1; extra == 'dev'
28
+ Requires-Dist: pytest>=7; extra == 'dev'
29
+ Requires-Dist: ruff>=0.1; extra == 'dev'
30
+ Requires-Dist: twine<7,>=5; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # qerexa (Python, preview)
34
+
35
+ Python clients for Qerexa:
36
+
37
+ - `QerexaGateMiddleware` — decorator that runs a function only after a
38
+ direct, unexpired Qerexa `allowed` decision. Works for AutoGen functions and
39
+ any plain Python function, including CrewAI or LangChain tool functions.
40
+ - `QerexaVCClient` — issue and verify verifiable credentials, federation
41
+ tokens, and trust relationships.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install qerexa
47
+ ```
48
+
49
+ ## Gate a function
50
+
51
+ ```python
52
+ import os
53
+ from qerexa import (
54
+ ExecutionAuthorizationRequiredError,
55
+ GateBlockedError,
56
+ QerexaGateMiddleware,
57
+ )
58
+
59
+ gate = QerexaGateMiddleware(api_key=os.environ["QEREXA_API_KEY"])
60
+
61
+ @gate.gate_check(action="report.export", reasoning="Monthly compliance export")
62
+ def export_report(report_id: str) -> str:
63
+ ...
64
+
65
+ try:
66
+ export_report("rpt_123")
67
+ except ExecutionAuthorizationRequiredError as e:
68
+ # Human review was requested (flow e.flow_id). Approval of that review does
69
+ # not authorize this call; stop here or use an action-bound execution flow.
70
+ ...
71
+ except GateBlockedError as e:
72
+ ...
73
+ ```
74
+
75
+ The wrapped function runs only for HTTP 200 with `decision: "allowed"`,
76
+ `allowed: true`, a `decision_id`, and a future, timezone-aware `expires_at`.
77
+ Everything else raises and the function does not run: `blocked`,
78
+ `approval_required`, expired or malformed allows, non-JSON responses, and
79
+ network errors (`QerexaError`, the base of both errors above).
80
+
81
+ Function arguments are never sent. Qerexa receives the action, the actor, the
82
+ `reasoning` you pass (default: the function name), and `metadata`. Do not put
83
+ credentials or customer data in either.
84
+
85
+ For CrewAI, decorate the function that implements the tool before handing it
86
+ to CrewAI; the gate then runs on every call regardless of CrewAI callbacks.
87
+
88
+ ## Verifiable credentials
89
+
90
+ ```python
91
+ from qerexa import VCClientConfig, create_vc_client
92
+
93
+ vc = create_vc_client(VCClientConfig(org_id="org_123", api_key=os.environ["QEREXA_API_KEY"]))
94
+ relationships = vc.list_trust_relationships(limit=10)
95
+ ```
96
+
97
+ Requests time out after 10 seconds by default (`VCClientConfig(timeout=...)`).
98
+
99
+ ## Preview limits
100
+
101
+ This package is not a regulated execution adapter. It does not verify
102
+ receipts, poll or resume review flows, or bind an approval to a specific
103
+ execution. Use the documented API proof flow and an independently reviewed
104
+ execution contract for high-impact actions.
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ python -m pip install -e "./packages/sdk-python[dev]"
110
+ ruff check packages/sdk-python
111
+ pytest packages/sdk-python
112
+ ```
113
+
114
+ Releases follow [the SDK release runbook](../../apps/sdk/SDK_RELEASE.md).
qerexa-0.1.0/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # qerexa (Python, preview)
2
+
3
+ Python clients for Qerexa:
4
+
5
+ - `QerexaGateMiddleware` — decorator that runs a function only after a
6
+ direct, unexpired Qerexa `allowed` decision. Works for AutoGen functions and
7
+ any plain Python function, including CrewAI or LangChain tool functions.
8
+ - `QerexaVCClient` — issue and verify verifiable credentials, federation
9
+ tokens, and trust relationships.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install qerexa
15
+ ```
16
+
17
+ ## Gate a function
18
+
19
+ ```python
20
+ import os
21
+ from qerexa import (
22
+ ExecutionAuthorizationRequiredError,
23
+ GateBlockedError,
24
+ QerexaGateMiddleware,
25
+ )
26
+
27
+ gate = QerexaGateMiddleware(api_key=os.environ["QEREXA_API_KEY"])
28
+
29
+ @gate.gate_check(action="report.export", reasoning="Monthly compliance export")
30
+ def export_report(report_id: str) -> str:
31
+ ...
32
+
33
+ try:
34
+ export_report("rpt_123")
35
+ except ExecutionAuthorizationRequiredError as e:
36
+ # Human review was requested (flow e.flow_id). Approval of that review does
37
+ # not authorize this call; stop here or use an action-bound execution flow.
38
+ ...
39
+ except GateBlockedError as e:
40
+ ...
41
+ ```
42
+
43
+ The wrapped function runs only for HTTP 200 with `decision: "allowed"`,
44
+ `allowed: true`, a `decision_id`, and a future, timezone-aware `expires_at`.
45
+ Everything else raises and the function does not run: `blocked`,
46
+ `approval_required`, expired or malformed allows, non-JSON responses, and
47
+ network errors (`QerexaError`, the base of both errors above).
48
+
49
+ Function arguments are never sent. Qerexa receives the action, the actor, the
50
+ `reasoning` you pass (default: the function name), and `metadata`. Do not put
51
+ credentials or customer data in either.
52
+
53
+ For CrewAI, decorate the function that implements the tool before handing it
54
+ to CrewAI; the gate then runs on every call regardless of CrewAI callbacks.
55
+
56
+ ## Verifiable credentials
57
+
58
+ ```python
59
+ from qerexa import VCClientConfig, create_vc_client
60
+
61
+ vc = create_vc_client(VCClientConfig(org_id="org_123", api_key=os.environ["QEREXA_API_KEY"]))
62
+ relationships = vc.list_trust_relationships(limit=10)
63
+ ```
64
+
65
+ Requests time out after 10 seconds by default (`VCClientConfig(timeout=...)`).
66
+
67
+ ## Preview limits
68
+
69
+ This package is not a regulated execution adapter. It does not verify
70
+ receipts, poll or resume review flows, or bind an approval to a specific
71
+ execution. Use the documented API proof flow and an independently reviewed
72
+ execution contract for high-impact actions.
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ python -m pip install -e "./packages/sdk-python[dev]"
78
+ ruff check packages/sdk-python
79
+ pytest packages/sdk-python
80
+ ```
81
+
82
+ Releases follow [the SDK release runbook](../../apps/sdk/SDK_RELEASE.md).
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ # Keep emitted Core Metadata compatible with the current Twine/PyPI path.
3
+ # Hatchling 1.28+ may emit Metadata-Version 2.5, which Twine 6 rejects.
4
+ requires = ["hatchling>=1.26,<1.28"]
5
+ build-backend = "hatchling.build"
6
+
7
+ [project]
8
+ name = "qerexa"
9
+ dynamic = ["version"]
10
+ description = "Qerexa clients for verifiable credentials and fail-closed gate checks (preview)."
11
+ readme = "README.md"
12
+ requires-python = ">=3.10"
13
+ license = "MIT"
14
+ license-files = ["LICENSE"]
15
+ authors = [{ name = "Qerexa" }]
16
+ keywords = ["ai-agents", "governance", "trust", "verifiable-credentials", "autogen"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Typing :: Typed",
27
+ "Topic :: Security",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ ]
30
+ dependencies = [
31
+ "requests>=2.28,<3",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "build>=1,<2",
37
+ "pytest>=7",
38
+ "ruff>=0.1",
39
+ "twine>=5,<7",
40
+ ]
41
+
42
+ [project.urls]
43
+ Homepage = "https://qerexa.com"
44
+ Documentation = "https://qerexa.com/docs"
45
+ Repository = "https://github.com/rodriguew/Trust-Rails/tree/main/packages/sdk-python"
46
+ Issues = "https://github.com/rodriguew/Trust-Rails/issues"
47
+
48
+ [tool.hatch.version]
49
+ path = "qerexa/_version.py"
50
+
51
+ [tool.hatch.build.targets.wheel]
52
+ packages = ["qerexa"]
53
+
54
+ [tool.hatch.build.targets.sdist]
55
+ include = ["qerexa", "tests", "README.md", "LICENSE", "pyproject.toml"]
56
+
57
+ [tool.ruff]
58
+ line-length = 100
59
+ target-version = "py310"
60
+
61
+ [tool.ruff.lint]
62
+ select = ["E", "F"]
63
+
64
+ [tool.pytest.ini_options]
65
+ testpaths = ["tests"]
@@ -0,0 +1,22 @@
1
+ """Qerexa Python clients (preview).
2
+
3
+ ``QerexaGateMiddleware`` runs a wrapped function only after a direct,
4
+ unexpired Qerexa ``allowed`` decision. ``QerexaVCClient`` manages verifiable
5
+ credentials, federation tokens, and trust relationships.
6
+ """
7
+
8
+ from ._version import __version__
9
+ from .autogen_middleware import QerexaGateMiddleware
10
+ from .gate import ExecutionAuthorizationRequiredError, GateBlockedError, QerexaError
11
+ from .vc_client import QerexaVCClient, VCClientConfig, create_vc_client
12
+
13
+ __all__ = [
14
+ "ExecutionAuthorizationRequiredError",
15
+ "GateBlockedError",
16
+ "QerexaError",
17
+ "QerexaGateMiddleware",
18
+ "QerexaVCClient",
19
+ "VCClientConfig",
20
+ "__version__",
21
+ "create_vc_client",
22
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,203 @@
1
+ """Fail-closed Qerexa gate decorator for AutoGen (or any Python) functions."""
2
+
3
+ import logging
4
+ from functools import wraps
5
+ from typing import Any, Callable, Dict, Optional, Tuple
6
+
7
+ import requests
8
+
9
+ from .gate import QerexaError, enforce, read_string
10
+
11
+ logger = logging.getLogger("qerexa")
12
+
13
+
14
+ class GateEvaluationRequest:
15
+ """Gate evaluation request payload."""
16
+
17
+ def __init__(
18
+ self,
19
+ action: str,
20
+ actor: str,
21
+ reasoning: str,
22
+ scope: Optional[Dict[str, Any]] = None,
23
+ metadata: Optional[Dict[str, Any]] = None,
24
+ federation_token: Optional[str] = None,
25
+ ):
26
+ self.action = action
27
+ self.actor = actor
28
+ self.reasoning = reasoning
29
+ self.scope = scope
30
+ self.metadata = metadata or {}
31
+ self.federation_token = federation_token
32
+
33
+ def to_dict(self) -> Dict[str, Any]:
34
+ result = {
35
+ "action": self.action,
36
+ "actor": self.actor,
37
+ "reasoning": self.reasoning,
38
+ "scope": self.scope,
39
+ "metadata": self.metadata,
40
+ }
41
+ if self.federation_token:
42
+ result["federation_token"] = self.federation_token
43
+ return result
44
+
45
+
46
+ class QerexaGateMiddleware:
47
+ """
48
+ Wraps functions so they run only after a direct, unexpired Qerexa allow.
49
+
50
+ ``approval_required`` raises ExecutionAuthorizationRequiredError; blocked,
51
+ expired, malformed, and failed requests raise GateBlockedError or
52
+ QerexaError. Function arguments are never sent to Qerexa: the gate sees
53
+ the action, the function name (or your ``reasoning``), and ``metadata``.
54
+
55
+ Usage:
56
+ middleware = QerexaGateMiddleware(api_key=os.environ["QEREXA_API_KEY"])
57
+
58
+ @middleware.gate_check(action="report.export")
59
+ def export_report(report_id: str) -> str:
60
+ ...
61
+
62
+ The decorator wraps the function itself, so it also guards CrewAI or
63
+ LangChain tools whose implementation is a plain Python function.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ api_key: str,
69
+ api_url: str = "https://api.qerexa.com/api/v1/gates/evaluate",
70
+ actor: str = "autogen-agent",
71
+ federation_token: Optional[str] = None,
72
+ timeout: float = 10.0,
73
+ ):
74
+ """
75
+ Args:
76
+ api_key: Qerexa API key (Bearer token)
77
+ api_url: Qerexa Gate API endpoint
78
+ actor: Actor identifier for gate logs
79
+ federation_token: Optional federation token for cross-org execution
80
+ timeout: Request timeout in seconds
81
+ """
82
+ self.api_key = api_key
83
+ self.api_url = api_url
84
+ self.actor = actor
85
+ self.federation_token = federation_token
86
+ self.timeout = timeout
87
+
88
+ def _evaluate(
89
+ self,
90
+ action: str,
91
+ reasoning: str,
92
+ metadata: Optional[Dict[str, Any]],
93
+ federation_token: Optional[str],
94
+ ) -> Tuple[int, Any]:
95
+ request = GateEvaluationRequest(
96
+ action=action,
97
+ actor=self.actor,
98
+ reasoning=reasoning,
99
+ metadata=metadata or {},
100
+ federation_token=federation_token or self.federation_token,
101
+ )
102
+ try:
103
+ response = requests.post(
104
+ self.api_url,
105
+ json=request.to_dict(),
106
+ headers={
107
+ "Content-Type": "application/json",
108
+ "Authorization": f"Bearer {self.api_key}",
109
+ },
110
+ timeout=self.timeout,
111
+ )
112
+ except requests.RequestException as exc:
113
+ raise QerexaError(f"Failed to reach Qerexa gate for {action}: {exc}") from exc
114
+ try:
115
+ data = response.json()
116
+ except ValueError:
117
+ data = None
118
+ return response.status_code, data
119
+
120
+ def evaluate_gate(
121
+ self,
122
+ action: str,
123
+ reasoning: str,
124
+ metadata: Optional[Dict[str, Any]] = None,
125
+ federation_token: Optional[str] = None,
126
+ ) -> Dict[str, Any]:
127
+ """
128
+ Return the raw gate response body without enforcing it.
129
+
130
+ Use gate_check() or wrap_function() to enforce the decision.
131
+
132
+ Raises:
133
+ QerexaError: If the API cannot be reached or returns a non-object body
134
+ """
135
+ _, data = self._evaluate(action, reasoning, metadata, federation_token)
136
+ if not isinstance(data, dict):
137
+ raise QerexaError(f"Qerexa gate returned an invalid response for {action}")
138
+ return data
139
+
140
+ def gate_check(
141
+ self,
142
+ action: str,
143
+ metadata: Optional[Dict[str, Any]] = None,
144
+ federation_token: Optional[str] = None,
145
+ reasoning: Optional[str] = None,
146
+ ):
147
+ """
148
+ Decorator that runs the function only after a direct, unexpired allow.
149
+
150
+ Args:
151
+ action: Action being gated (e.g., "report.export")
152
+ metadata: Custom metadata for the audit trail; do not include secrets
153
+ federation_token: Optional federation token for cross-org execution
154
+ reasoning: Why the action is needed; defaults to the function name
155
+
156
+ Example:
157
+ @middleware.gate_check(action="report.export")
158
+ def export_report(report_id: str) -> str:
159
+ ...
160
+ """
161
+
162
+ def decorator(func: Callable) -> Callable:
163
+ @wraps(func)
164
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
165
+ status, data = self._evaluate(
166
+ action,
167
+ reasoning or f"Function call: {func.__name__}",
168
+ metadata or {"function": func.__name__},
169
+ federation_token,
170
+ )
171
+ try:
172
+ enforce(action, status, data)
173
+ except QerexaError as error:
174
+ logger.warning("Qerexa gate stopped %s: %s", func.__name__, error)
175
+ raise
176
+ decision_id = read_string(data.get("decision_id"))
177
+ logger.info("Qerexa allowed %s (decision_id: %s)", func.__name__, decision_id)
178
+ return func(*args, **kwargs)
179
+
180
+ return wrapper
181
+
182
+ return decorator
183
+
184
+ def wrap_function(
185
+ self,
186
+ func: Callable,
187
+ action: str,
188
+ metadata: Optional[Dict[str, Any]] = None,
189
+ federation_token: Optional[str] = None,
190
+ reasoning: Optional[str] = None,
191
+ ) -> Callable:
192
+ """
193
+ Wrap a function with a gate check at runtime (same rules as gate_check).
194
+
195
+ Example:
196
+ gated_export = middleware.wrap_function(export_report, action="report.export")
197
+ """
198
+ return self.gate_check(
199
+ action=action,
200
+ metadata=metadata,
201
+ federation_token=federation_token,
202
+ reasoning=reasoning,
203
+ )(func)
@@ -0,0 +1,103 @@
1
+ """Gate decision parsing shared by the Qerexa Python helpers.
2
+
3
+ Only a direct, well-formed, unexpired ``allowed`` decision lets an action run.
4
+ Review approval is never execution authorization. This mirrors the decision
5
+ rules of the ``qerexa-langchain`` client.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from datetime import datetime
12
+ from typing import Any, Optional
13
+
14
+
15
+ class QerexaError(Exception):
16
+ """Base error for Qerexa SDK failures. The guarded action did not run."""
17
+
18
+
19
+ class GateBlockedError(QerexaError):
20
+ """The gate blocked the action, or the response was not a valid allow."""
21
+
22
+ def __init__(self, action: str, reason: str, decision_id: Optional[str] = None):
23
+ super().__init__(f"Qerexa blocked {action}: {reason}")
24
+ self.action = action
25
+ self.reason = reason
26
+ self.decision_id = decision_id
27
+
28
+
29
+ class ExecutionAuthorizationRequiredError(QerexaError):
30
+ """Human review is required. Approval alone does not authorize execution."""
31
+
32
+ def __init__(self, action: str, flow_id: Optional[str], decision_id: Optional[str] = None):
33
+ super().__init__(
34
+ f"Qerexa requires review for {action} (flow {flow_id}); review approval is not "
35
+ "execution authorization, so the action was not run"
36
+ )
37
+ self.action = action
38
+ self.flow_id = flow_id
39
+ self.decision_id = decision_id
40
+
41
+
42
+ def read_string(value: Any) -> Optional[str]:
43
+ return value if isinstance(value, str) and value else None
44
+
45
+
46
+ def _timestamp(value: str) -> float:
47
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
48
+ if parsed.tzinfo is None:
49
+ raise ValueError("timestamp must include a timezone")
50
+ return parsed.timestamp()
51
+
52
+
53
+ def is_direct_allow(status: int, data: Any) -> bool:
54
+ """True only for HTTP 200 with a complete, unexpired ``allowed`` decision."""
55
+ if status != 200 or not isinstance(data, dict):
56
+ return False
57
+ expires_at = read_string(data.get("expires_at"))
58
+ if not (
59
+ data.get("decision") == "allowed"
60
+ and data.get("allowed") is True
61
+ and read_string(data.get("decision_id"))
62
+ and expires_at
63
+ ):
64
+ return False
65
+ try:
66
+ return _timestamp(expires_at) > time.time()
67
+ except ValueError:
68
+ return False
69
+
70
+
71
+ def is_review_required(status: int, data: Any) -> bool:
72
+ return (
73
+ status == 202
74
+ and isinstance(data, dict)
75
+ and data.get("decision") == "approval_required"
76
+ and data.get("allowed") is False
77
+ and read_string(data.get("flow_id")) is not None
78
+ )
79
+
80
+
81
+ def block_reason(data: Any, status: int) -> str:
82
+ if isinstance(data, dict):
83
+ reason = read_string(data.get("reason")) or read_string(data.get("error"))
84
+ if reason:
85
+ return reason
86
+ gates = data.get("gates_checked")
87
+ if isinstance(gates, list):
88
+ for gate in gates:
89
+ if isinstance(gate, dict) and gate.get("status") == "failed":
90
+ detail = read_string(gate.get("reason")) or read_string(gate.get("gate"))
91
+ if detail:
92
+ return detail
93
+ return f"no valid allow decision (HTTP {status})"
94
+
95
+
96
+ def enforce(action: str, status: int, data: Any) -> None:
97
+ """Return normally only for a direct allow; otherwise raise."""
98
+ if is_direct_allow(status, data):
99
+ return
100
+ decision_id = read_string(data.get("decision_id")) if isinstance(data, dict) else None
101
+ if is_review_required(status, data):
102
+ raise ExecutionAuthorizationRequiredError(action, data["flow_id"], decision_id)
103
+ raise GateBlockedError(action, block_reason(data, status), decision_id)
File without changes
@@ -0,0 +1,196 @@
1
+ """
2
+ WS4 Verifiable Credentials SDK (Python)
3
+ Client for issuing and verifying VCs across organizations
4
+ """
5
+
6
+ from typing import Optional, Dict, Any
7
+ from dataclasses import dataclass
8
+ from urllib.parse import quote
9
+ import requests
10
+
11
+ from .gate import QerexaError
12
+
13
+
14
+ @dataclass
15
+ class VCClientConfig:
16
+ """Configuration for VC Client"""
17
+ org_id: str
18
+ api_key: str
19
+ base_url: str = "https://api.qerexa.com"
20
+ timeout: float = 10.0
21
+
22
+
23
+ class QerexaVCClient:
24
+ """SDK client for Qerexa Verifiable Credentials"""
25
+
26
+ def __init__(self, config: VCClientConfig):
27
+ self.org_id = config.org_id
28
+ self.api_key = config.api_key
29
+ self.base_url = config.base_url
30
+ self.timeout = config.timeout
31
+ self.session = requests.Session()
32
+ self.session.headers.update({
33
+ "x-org-id": self.org_id,
34
+ "Authorization": f"Bearer {self.api_key}",
35
+ "Content-Type": "application/json",
36
+ })
37
+
38
+ def issue_competence_vc(
39
+ self,
40
+ member_id: str,
41
+ include_reputation: bool = True,
42
+ include_hardware_key: bool = True,
43
+ ttl_days: Optional[int] = None,
44
+ ) -> Dict[str, Any]:
45
+ """Issue a competence credential for a member"""
46
+ payload = {
47
+ "member_id": member_id,
48
+ "include_reputation": include_reputation,
49
+ "include_hardware_key": include_hardware_key,
50
+ }
51
+ if ttl_days:
52
+ payload["ttl_days"] = ttl_days
53
+
54
+ return self._request("POST", "/api/v1/vc/issue/competence", payload)
55
+
56
+ def issue_risk_assessment_vc(
57
+ self,
58
+ approval_id: str,
59
+ actor_name: str,
60
+ action: str,
61
+ risk_level: str,
62
+ risk_score: int,
63
+ approved_by_member_id: str,
64
+ amount_usd: Optional[int] = None,
65
+ ttl_days: Optional[int] = None,
66
+ ) -> Dict[str, Any]:
67
+ """Issue a risk assessment credential"""
68
+ payload = {
69
+ "approval_id": approval_id,
70
+ "actor_name": actor_name,
71
+ "action": action,
72
+ "risk_level": risk_level,
73
+ "risk_score": risk_score,
74
+ "approved_by_member_id": approved_by_member_id,
75
+ }
76
+ if amount_usd:
77
+ payload["amount_usd"] = amount_usd
78
+ if ttl_days:
79
+ payload["ttl_days"] = ttl_days
80
+
81
+ return self._request("POST", "/api/v1/vc/issue/risk-assessment", payload)
82
+
83
+ def generate_federation_token(
84
+ self,
85
+ recipient_org_id: str,
86
+ member_id: str,
87
+ domains: Optional[list] = None,
88
+ ttl_hours: Optional[int] = None,
89
+ ) -> Dict[str, Any]:
90
+ """Generate a federation token for cross-org approval"""
91
+ payload = {
92
+ "recipient_org_id": recipient_org_id,
93
+ "member_id": member_id,
94
+ }
95
+ if not domains:
96
+ raise ValueError("domains must contain at least one approved domain")
97
+ payload["domains"] = domains
98
+ if ttl_hours:
99
+ payload["ttl_hours"] = ttl_hours
100
+
101
+ return self._request("POST", "/api/v1/federation/tokens", payload)
102
+
103
+ def verify_federation_token(self, token: str) -> Dict[str, Any]:
104
+ """Verify a federation token"""
105
+ payload = {"token": token}
106
+ return self._request("POST", "/api/v1/federation/verify", payload)
107
+
108
+ def create_trust_relationship(
109
+ self,
110
+ trusted_org_id: str,
111
+ trust_level: str,
112
+ approved_domains: Optional[list] = None,
113
+ expires_at: Optional[str] = None,
114
+ ) -> Dict[str, Any]:
115
+ """Create a trust relationship with another organization"""
116
+ payload = {
117
+ "trusted_org_id": trusted_org_id,
118
+ "trust_level": trust_level,
119
+ }
120
+ if not approved_domains:
121
+ raise ValueError("approved_domains must contain at least one domain")
122
+ payload["approved_domains"] = approved_domains
123
+ if expires_at:
124
+ payload["expires_at"] = expires_at
125
+
126
+ return self._request("POST", "/api/v1/trust-relationships", payload)
127
+
128
+ def list_trust_relationships(
129
+ self,
130
+ limit: int = 50,
131
+ offset: int = 0,
132
+ ) -> Dict[str, Any]:
133
+ """List trust relationships"""
134
+ params = {"limit": limit, "offset": offset}
135
+ return self._request("GET", "/api/v1/trust-relationships", params=params)
136
+
137
+ def list_federation_tokens(
138
+ self,
139
+ limit: int = 50,
140
+ offset: int = 0,
141
+ ) -> Dict[str, Any]:
142
+ """List federation tokens received"""
143
+ params = {"limit": limit, "offset": offset}
144
+ return self._request("GET", "/api/v1/federation/tokens", params=params)
145
+
146
+ def revoke_federation_token(
147
+ self, token_jti: str, reason: Optional[str] = None
148
+ ) -> Dict[str, Any]:
149
+ payload = {"reason": reason} if reason else {}
150
+ return self._request(
151
+ "POST", f"/api/v1/federation/tokens/{quote(token_jti, safe='')}/revoke", payload
152
+ )
153
+
154
+ def revoke_credential(
155
+ self, credential_id: str, reason: Optional[str] = None
156
+ ) -> Dict[str, Any]:
157
+ payload = {"reason": reason} if reason else {}
158
+ return self._request(
159
+ "POST", f"/api/v1/vc/{quote(credential_id, safe='')}/revoke", payload
160
+ )
161
+
162
+ def rotate_signing_key(self) -> Dict[str, Any]:
163
+ return self._request("POST", "/api/v1/vc/keys/rotate", {})
164
+
165
+ def revoke_signing_key(self, key_id: str) -> Dict[str, Any]:
166
+ return self._request(
167
+ "POST", f"/api/v1/vc/keys/{quote(key_id, safe='')}/revoke", {}
168
+ )
169
+
170
+ def _request(
171
+ self,
172
+ method: str,
173
+ path: str,
174
+ payload: Optional[Dict[str, Any]] = None,
175
+ params: Optional[Dict[str, Any]] = None,
176
+ ) -> Dict[str, Any]:
177
+ """Make authenticated API request"""
178
+ url = f"{self.base_url}{path}"
179
+
180
+ try:
181
+ if method == "GET":
182
+ response = self.session.get(url, params=params, timeout=self.timeout)
183
+ elif method == "POST":
184
+ response = self.session.post(url, json=payload, timeout=self.timeout)
185
+ else:
186
+ raise ValueError(f"Unsupported HTTP method: {method}")
187
+
188
+ response.raise_for_status()
189
+ return response.json()
190
+ except requests.exceptions.RequestException as e:
191
+ raise QerexaError(f"API Error: {str(e)}") from e
192
+
193
+
194
+ def create_vc_client(config: VCClientConfig) -> QerexaVCClient:
195
+ """Factory function for quick SDK setup"""
196
+ return QerexaVCClient(config)
@@ -0,0 +1,175 @@
1
+ from datetime import datetime, timedelta, timezone
2
+ from unittest import mock
3
+
4
+ import pytest
5
+ import requests
6
+
7
+ import qerexa
8
+ from qerexa import (
9
+ ExecutionAuthorizationRequiredError,
10
+ GateBlockedError,
11
+ QerexaError,
12
+ QerexaGateMiddleware,
13
+ QerexaVCClient,
14
+ VCClientConfig,
15
+ )
16
+
17
+
18
+ def _iso(delta_seconds):
19
+ return (datetime.now(timezone.utc) + timedelta(seconds=delta_seconds)).isoformat()
20
+
21
+
22
+ def _response(status, body):
23
+ response = mock.Mock()
24
+ response.ok = 200 <= status < 300
25
+ response.status_code = status
26
+ if isinstance(body, Exception):
27
+ response.json.side_effect = body
28
+ else:
29
+ response.json.return_value = body
30
+ return response
31
+
32
+
33
+ def _allow(**overrides):
34
+ body = {
35
+ "decision": "allowed",
36
+ "allowed": True,
37
+ "decision_id": "dec_1",
38
+ "expires_at": _iso(300),
39
+ "gates_checked": [],
40
+ }
41
+ body.update(overrides)
42
+ return body
43
+
44
+
45
+ def _run_gated(status, body, **gate_kwargs):
46
+ middleware = QerexaGateMiddleware(api_key="qrx_test")
47
+ executed = []
48
+
49
+ @middleware.gate_check(action="report.export", **gate_kwargs)
50
+ def export_report(report_id, secret_note=None):
51
+ executed.append(report_id)
52
+ return "ok"
53
+
54
+ with mock.patch(
55
+ "qerexa.autogen_middleware.requests.post", return_value=_response(status, body)
56
+ ) as post:
57
+ try:
58
+ result = export_report("rpt_1", secret_note="hunter2")
59
+ except QerexaError as error:
60
+ return None, error, executed, post
61
+ return result, None, executed, post
62
+
63
+
64
+ def test_version_is_exposed():
65
+ assert isinstance(qerexa.__version__, str) and qerexa.__version__
66
+
67
+
68
+ def test_direct_unexpired_allow_runs_the_function():
69
+ result, error, executed, _ = _run_gated(200, _allow())
70
+ assert error is None and result == "ok" and executed == ["rpt_1"]
71
+
72
+
73
+ @pytest.mark.parametrize(
74
+ "status, body",
75
+ [
76
+ (200, _allow(expires_at=_iso(-1))), # expired
77
+ (200, _allow(expires_at=None)), # no expiry
78
+ (200, _allow(expires_at="2999-01-01T00:00:00")), # no timezone
79
+ (200, _allow(expires_at="not-a-date")),
80
+ (200, _allow(allowed="true")), # truthy but not True
81
+ (200, _allow(decision="approval_required")),
82
+ (200, _allow(decision_id="")),
83
+ (201, _allow()), # allow body on an unexpected status
84
+ (200, ["allowed"]), # not an object
85
+ (200, ValueError("not json")),
86
+ ],
87
+ )
88
+ def test_anything_but_a_valid_direct_allow_is_blocked(status, body):
89
+ _, error, executed, _ = _run_gated(status, body)
90
+ assert isinstance(error, GateBlockedError)
91
+ assert executed == []
92
+
93
+
94
+ def test_blocked_decision_reports_the_failed_gate():
95
+ body = {
96
+ "decision": "blocked",
97
+ "allowed": False,
98
+ "decision_id": "dec_2",
99
+ "gates_checked": [{"gate": "hipaa", "status": "failed", "reason": "phi detected"}],
100
+ }
101
+ _, error, executed, _ = _run_gated(403, body)
102
+ assert isinstance(error, GateBlockedError)
103
+ assert error.reason == "phi detected" and error.decision_id == "dec_2"
104
+ assert executed == []
105
+
106
+
107
+ def test_review_required_never_runs_the_function():
108
+ body = {
109
+ "decision": "approval_required",
110
+ "allowed": False,
111
+ "decision_id": "dec_3",
112
+ "flow_id": "flow_1",
113
+ "gates_checked": [],
114
+ }
115
+ _, error, executed, _ = _run_gated(202, body)
116
+ assert isinstance(error, ExecutionAuthorizationRequiredError)
117
+ assert error.flow_id == "flow_1"
118
+ assert executed == []
119
+
120
+
121
+ def test_network_failure_fails_closed():
122
+ middleware = QerexaGateMiddleware(api_key="qrx_test")
123
+ executed = []
124
+
125
+ @middleware.gate_check(action="report.export")
126
+ def export_report():
127
+ executed.append(True)
128
+
129
+ with mock.patch(
130
+ "qerexa.autogen_middleware.requests.post",
131
+ side_effect=requests.ConnectionError("down"),
132
+ ):
133
+ with pytest.raises(QerexaError):
134
+ export_report()
135
+ assert executed == []
136
+
137
+
138
+ def test_function_arguments_are_never_sent():
139
+ _, _, _, post = _run_gated(200, _allow())
140
+ sent = post.call_args.kwargs["json"]
141
+ assert "rpt_1" not in str(sent) and "hunter2" not in str(sent)
142
+ assert sent["reasoning"] == "Function call: export_report"
143
+ assert post.call_args.kwargs["timeout"] == 10.0
144
+
145
+
146
+ def test_explicit_reasoning_is_used():
147
+ _, _, _, post = _run_gated(200, _allow(), reasoning="Monthly export")
148
+ assert post.call_args.kwargs["json"]["reasoning"] == "Monthly export"
149
+
150
+
151
+ def test_vc_client_sends_bearer_and_org_headers():
152
+ client = QerexaVCClient(VCClientConfig(org_id="org_1", api_key="qrx_test"))
153
+ assert client.session.headers["Authorization"] == "Bearer qrx_test"
154
+ assert client.session.headers["x-org-id"] == "org_1"
155
+
156
+
157
+ def test_vc_client_rejects_empty_federation_domains():
158
+ client = QerexaVCClient(VCClientConfig(org_id="org_1", api_key="qrx_test"))
159
+ with pytest.raises(ValueError):
160
+ client.generate_federation_token("org_2", "member_1", domains=[])
161
+
162
+
163
+ def test_vc_client_quotes_path_parameters_and_sets_timeout():
164
+ client = QerexaVCClient(VCClientConfig(org_id="org_1", api_key="qrx_test", timeout=3))
165
+ with mock.patch.object(client.session, "post", return_value=_response(200, {})) as post:
166
+ client.revoke_credential("a/b")
167
+ assert post.call_args.args[0].endswith("/api/v1/vc/a%2Fb/revoke")
168
+ assert post.call_args.kwargs["timeout"] == 3
169
+
170
+
171
+ def test_vc_client_wraps_transport_errors():
172
+ client = QerexaVCClient(VCClientConfig(org_id="org_1", api_key="qrx_test"))
173
+ with mock.patch.object(client.session, "get", side_effect=requests.ConnectionError("down")):
174
+ with pytest.raises(QerexaError):
175
+ client.list_trust_relationships()