qerexa 0.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.
- qerexa/__init__.py +22 -0
- qerexa/_version.py +1 -0
- qerexa/autogen_middleware.py +203 -0
- qerexa/gate.py +103 -0
- qerexa/py.typed +0 -0
- qerexa/vc_client.py +196 -0
- qerexa-0.1.0.dist-info/METADATA +114 -0
- qerexa-0.1.0.dist-info/RECORD +10 -0
- qerexa-0.1.0.dist-info/WHEEL +4 -0
- qerexa-0.1.0.dist-info/licenses/LICENSE +21 -0
qerexa/__init__.py
ADDED
|
@@ -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
|
+
]
|
qerexa/_version.py
ADDED
|
@@ -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)
|
qerexa/gate.py
ADDED
|
@@ -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)
|
qerexa/py.typed
ADDED
|
File without changes
|
qerexa/vc_client.py
ADDED
|
@@ -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,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).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
qerexa/__init__.py,sha256=imN2Ee8Ds7cb_hSx-uXqABpvITGH6sH8-0IgiRfJBvs,706
|
|
2
|
+
qerexa/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
3
|
+
qerexa/autogen_middleware.py,sha256=j6Q4SjWIrU2d3SbjQXOPjyT4U6cYqRnTT9jarOedyi8,6759
|
|
4
|
+
qerexa/gate.py,sha256=QDD6vtvb4-mWMXo0l_SelA1H2HdmF7IZxOE_1Uzkex0,3658
|
|
5
|
+
qerexa/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
qerexa/vc_client.py,sha256=6cDxkUgIyIZ8wWhQLRH5DdT5pGSdXNj5EzfNw5yoQtg,6477
|
|
7
|
+
qerexa-0.1.0.dist-info/METADATA,sha256=2hGygqCFiSgbz1XkMhD-KGf_WGsDV9lYFudic539ccE,3942
|
|
8
|
+
qerexa-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
+
qerexa-0.1.0.dist-info/licenses/LICENSE,sha256=0YvlA1nYl07sp4IyrhL9TGihfDXD8DGDErrrub5ZvZI,1068
|
|
10
|
+
qerexa-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|