faramesh-sdk 0.3.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.
- faramesh/__init__.py +178 -0
- faramesh/canonicalization.py +281 -0
- faramesh/client.py +1319 -0
- faramesh/decorators.py +154 -0
- faramesh/gate.py +377 -0
- faramesh/governed_tool.py +101 -0
- faramesh/policy.py +224 -0
- faramesh/policy_helpers.py +139 -0
- faramesh/snapshot.py +86 -0
- faramesh_sdk-0.3.0.dist-info/METADATA +169 -0
- faramesh_sdk-0.3.0.dist-info/RECORD +15 -0
- faramesh_sdk-0.3.0.dist-info/WHEEL +5 -0
- faramesh_sdk-0.3.0.dist-info/licenses/LICENSE +84 -0
- faramesh_sdk-0.3.0.dist-info/top_level.txt +2 -0
- tests/test_canonicalization.py +249 -0
faramesh/__init__.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Faramesh Python SDK - Production-ready client for the Faramesh Execution Governor API.
|
|
2
|
+
|
|
3
|
+
Quick Start:
|
|
4
|
+
>>> from faramesh import configure, submit_action, approve_action
|
|
5
|
+
>>> configure(base_url="http://localhost:8000", token="dev-token")
|
|
6
|
+
>>> action = submit_action("my-agent", "http", "get", {"url": "https://example.com"})
|
|
7
|
+
>>> print(f"Action {action['id']} status: {action['status']}")
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .client import (
|
|
11
|
+
# Configuration
|
|
12
|
+
configure,
|
|
13
|
+
ClientConfig,
|
|
14
|
+
|
|
15
|
+
# Core functions
|
|
16
|
+
submit_action,
|
|
17
|
+
submit_actions,
|
|
18
|
+
submit_actions_bulk,
|
|
19
|
+
submit_and_wait,
|
|
20
|
+
block_until_approved,
|
|
21
|
+
get_action,
|
|
22
|
+
list_actions,
|
|
23
|
+
approve_action,
|
|
24
|
+
deny_action,
|
|
25
|
+
start_action,
|
|
26
|
+
replay_action,
|
|
27
|
+
wait_for_completion,
|
|
28
|
+
apply,
|
|
29
|
+
tail_events,
|
|
30
|
+
stream_events,
|
|
31
|
+
|
|
32
|
+
# Convenience aliases
|
|
33
|
+
allow,
|
|
34
|
+
deny,
|
|
35
|
+
|
|
36
|
+
# Exceptions
|
|
37
|
+
FarameshError,
|
|
38
|
+
FarameshAuthError,
|
|
39
|
+
FarameshNotFoundError,
|
|
40
|
+
FarameshPolicyError,
|
|
41
|
+
FarameshTimeoutError,
|
|
42
|
+
FarameshConnectionError,
|
|
43
|
+
FarameshValidationError,
|
|
44
|
+
FarameshServerError,
|
|
45
|
+
FarameshBatchError,
|
|
46
|
+
FarameshDeniedError,
|
|
47
|
+
|
|
48
|
+
# Legacy class-based API (for backward compatibility)
|
|
49
|
+
ExecutionGovernorClient,
|
|
50
|
+
GovernorConfig,
|
|
51
|
+
GovernorError,
|
|
52
|
+
GovernorTimeoutError,
|
|
53
|
+
GovernorAuthError,
|
|
54
|
+
GovernorConnectionError,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Import new modules
|
|
58
|
+
from .governed_tool import governed_tool
|
|
59
|
+
from .snapshot import ActionSnapshotStore, get_default_store
|
|
60
|
+
from .policy_helpers import validate_policy_file, test_policy_against_action
|
|
61
|
+
|
|
62
|
+
# Import canonicalization helpers
|
|
63
|
+
from .canonicalization import (
|
|
64
|
+
canonicalize,
|
|
65
|
+
canonicalize_action_payload,
|
|
66
|
+
compute_request_hash,
|
|
67
|
+
compute_hash,
|
|
68
|
+
CanonicalizeError,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Import gate helpers
|
|
72
|
+
from .gate import (
|
|
73
|
+
gate_decide,
|
|
74
|
+
gate_decide_dict,
|
|
75
|
+
replay_decision,
|
|
76
|
+
verify_request_hash,
|
|
77
|
+
execute_if_allowed,
|
|
78
|
+
GateDecision,
|
|
79
|
+
ReplayResult,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Import version
|
|
83
|
+
from .client import __version__
|
|
84
|
+
|
|
85
|
+
# Import policy models
|
|
86
|
+
from .policy import (
|
|
87
|
+
Policy,
|
|
88
|
+
PolicyRule,
|
|
89
|
+
MatchCondition,
|
|
90
|
+
RiskRule,
|
|
91
|
+
RiskLevel,
|
|
92
|
+
create_policy,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
__all__ = [
|
|
96
|
+
# Configuration
|
|
97
|
+
"configure",
|
|
98
|
+
"ClientConfig",
|
|
99
|
+
|
|
100
|
+
# Core functions
|
|
101
|
+
"submit_action",
|
|
102
|
+
"submit_actions",
|
|
103
|
+
"submit_actions_bulk",
|
|
104
|
+
"submit_and_wait",
|
|
105
|
+
"block_until_approved",
|
|
106
|
+
"get_action",
|
|
107
|
+
"list_actions",
|
|
108
|
+
"approve_action",
|
|
109
|
+
"deny_action",
|
|
110
|
+
"start_action",
|
|
111
|
+
"replay_action",
|
|
112
|
+
"wait_for_completion",
|
|
113
|
+
"apply",
|
|
114
|
+
"tail_events",
|
|
115
|
+
"stream_events",
|
|
116
|
+
|
|
117
|
+
# Convenience aliases
|
|
118
|
+
"allow",
|
|
119
|
+
"deny",
|
|
120
|
+
|
|
121
|
+
# Policy models
|
|
122
|
+
"Policy",
|
|
123
|
+
"PolicyRule",
|
|
124
|
+
"MatchCondition",
|
|
125
|
+
"RiskRule",
|
|
126
|
+
"RiskLevel",
|
|
127
|
+
"create_policy",
|
|
128
|
+
|
|
129
|
+
# Policy helpers
|
|
130
|
+
"validate_policy_file",
|
|
131
|
+
"test_policy_against_action",
|
|
132
|
+
|
|
133
|
+
# Canonicalization helpers
|
|
134
|
+
"canonicalize",
|
|
135
|
+
"canonicalize_action_payload",
|
|
136
|
+
"compute_request_hash",
|
|
137
|
+
"compute_hash",
|
|
138
|
+
"CanonicalizeError",
|
|
139
|
+
|
|
140
|
+
# Gate/Replay helpers
|
|
141
|
+
"gate_decide",
|
|
142
|
+
"gate_decide_dict",
|
|
143
|
+
"replay_decision",
|
|
144
|
+
"verify_request_hash",
|
|
145
|
+
"execute_if_allowed",
|
|
146
|
+
"GateDecision",
|
|
147
|
+
"ReplayResult",
|
|
148
|
+
|
|
149
|
+
# Decorators
|
|
150
|
+
"governed_tool",
|
|
151
|
+
|
|
152
|
+
# Utilities
|
|
153
|
+
"ActionSnapshotStore",
|
|
154
|
+
"get_default_store",
|
|
155
|
+
|
|
156
|
+
# Exceptions
|
|
157
|
+
"FarameshError",
|
|
158
|
+
"FarameshAuthError",
|
|
159
|
+
"FarameshNotFoundError",
|
|
160
|
+
"FarameshPolicyError",
|
|
161
|
+
"FarameshTimeoutError",
|
|
162
|
+
"FarameshConnectionError",
|
|
163
|
+
"FarameshValidationError",
|
|
164
|
+
"FarameshServerError",
|
|
165
|
+
"FarameshBatchError",
|
|
166
|
+
"FarameshDeniedError",
|
|
167
|
+
|
|
168
|
+
# Legacy API
|
|
169
|
+
"ExecutionGovernorClient",
|
|
170
|
+
"GovernorConfig",
|
|
171
|
+
"GovernorError",
|
|
172
|
+
"GovernorTimeoutError",
|
|
173
|
+
"GovernorAuthError",
|
|
174
|
+
"GovernorConnectionError",
|
|
175
|
+
|
|
176
|
+
# Version
|
|
177
|
+
"__version__",
|
|
178
|
+
]
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deterministic canonicalization for Faramesh client-side hashing.
|
|
3
|
+
|
|
4
|
+
This module mirrors the canonicalization logic in faramesh-core to enable
|
|
5
|
+
clients to compute request_hash locally before submitting actions.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
>>> from faramesh.canonicalization import compute_request_hash
|
|
9
|
+
>>> payload = {"agent_id": "test", "tool": "http", "operation": "get", "params": {}}
|
|
10
|
+
>>> hash_value = compute_request_hash(payload)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import copy
|
|
16
|
+
import hashlib
|
|
17
|
+
import math
|
|
18
|
+
from decimal import Decimal, InvalidOperation
|
|
19
|
+
from typing import Any, Dict, List, Optional, Union
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CanonicalizeError(Exception):
|
|
23
|
+
"""
|
|
24
|
+
Raised when canonicalization fails.
|
|
25
|
+
|
|
26
|
+
This is a fail-closed error: if canonicalization cannot complete,
|
|
27
|
+
the action should not proceed.
|
|
28
|
+
"""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Fields to exclude from action payloads (ephemeral/internal)
|
|
33
|
+
_ACTION_EXCLUDE_FIELDS = frozenset({
|
|
34
|
+
"id",
|
|
35
|
+
"approval_token",
|
|
36
|
+
"created_at",
|
|
37
|
+
"updated_at",
|
|
38
|
+
"tenant_id",
|
|
39
|
+
"project_id",
|
|
40
|
+
"version",
|
|
41
|
+
"decision",
|
|
42
|
+
"status",
|
|
43
|
+
"reason",
|
|
44
|
+
"risk_level",
|
|
45
|
+
"policy_version",
|
|
46
|
+
"policy_hash",
|
|
47
|
+
"runtime_version",
|
|
48
|
+
"profile_id",
|
|
49
|
+
"profile_version",
|
|
50
|
+
"profile_hash",
|
|
51
|
+
"provenance_id",
|
|
52
|
+
"outcome",
|
|
53
|
+
"reason_code",
|
|
54
|
+
"reason_details",
|
|
55
|
+
"request_hash",
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _normalize_float(value: float) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Normalize a float to canonical string representation.
|
|
62
|
+
|
|
63
|
+
Rules:
|
|
64
|
+
- No exponent notation (1e3 → 1000)
|
|
65
|
+
- No trailing zeros (1.50 → 1.5)
|
|
66
|
+
- Whole numbers have no decimal point (1.0 → 1)
|
|
67
|
+
- NaN, Infinity, -Infinity → CanonicalizeError
|
|
68
|
+
"""
|
|
69
|
+
# FAIL CLOSED on non-finite values
|
|
70
|
+
if math.isnan(value):
|
|
71
|
+
raise CanonicalizeError("NaN is not allowed in canonical JSON")
|
|
72
|
+
if math.isinf(value):
|
|
73
|
+
raise CanonicalizeError("Infinity is not allowed in canonical JSON")
|
|
74
|
+
|
|
75
|
+
# Handle zero explicitly
|
|
76
|
+
if value == 0.0:
|
|
77
|
+
return "0"
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
dec = Decimal(repr(value))
|
|
81
|
+
dec = dec.normalize()
|
|
82
|
+
sign, digits, exponent = dec.as_tuple()
|
|
83
|
+
digit_str = ''.join(str(d) for d in digits)
|
|
84
|
+
|
|
85
|
+
if exponent >= 0:
|
|
86
|
+
result = digit_str + ('0' * exponent)
|
|
87
|
+
elif -exponent >= len(digit_str):
|
|
88
|
+
result = '0.' + ('0' * (-exponent - len(digit_str))) + digit_str
|
|
89
|
+
else:
|
|
90
|
+
decimal_pos = len(digit_str) + exponent
|
|
91
|
+
result = digit_str[:decimal_pos] + '.' + digit_str[decimal_pos:]
|
|
92
|
+
|
|
93
|
+
if sign:
|
|
94
|
+
result = '-' + result
|
|
95
|
+
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
except (InvalidOperation, ValueError) as e:
|
|
99
|
+
raise CanonicalizeError(f"Failed to normalize float {value!r}: {e}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _serialize_string(value: str) -> str:
|
|
103
|
+
"""Serialize a string to canonical JSON representation."""
|
|
104
|
+
result = ['"']
|
|
105
|
+
|
|
106
|
+
for char in value:
|
|
107
|
+
code = ord(char)
|
|
108
|
+
|
|
109
|
+
if char == '"':
|
|
110
|
+
result.append('\\"')
|
|
111
|
+
elif char == '\\':
|
|
112
|
+
result.append('\\\\')
|
|
113
|
+
elif char == '\b':
|
|
114
|
+
result.append('\\b')
|
|
115
|
+
elif char == '\f':
|
|
116
|
+
result.append('\\f')
|
|
117
|
+
elif char == '\n':
|
|
118
|
+
result.append('\\n')
|
|
119
|
+
elif char == '\r':
|
|
120
|
+
result.append('\\r')
|
|
121
|
+
elif char == '\t':
|
|
122
|
+
result.append('\\t')
|
|
123
|
+
elif code < 0x20:
|
|
124
|
+
result.append(f'\\u{code:04x}')
|
|
125
|
+
else:
|
|
126
|
+
result.append(char)
|
|
127
|
+
|
|
128
|
+
result.append('"')
|
|
129
|
+
return ''.join(result)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _serialize_dict(value: dict) -> str:
|
|
133
|
+
"""Serialize a dict to canonical JSON with sorted keys."""
|
|
134
|
+
for key in value:
|
|
135
|
+
if not isinstance(key, str):
|
|
136
|
+
raise CanonicalizeError(
|
|
137
|
+
f"Dict keys must be strings, got {type(key).__name__}: {key!r}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
sorted_keys = sorted(value.keys())
|
|
141
|
+
parts = []
|
|
142
|
+
for key in sorted_keys:
|
|
143
|
+
key_str = _serialize_string(key)
|
|
144
|
+
val_str = _serialize_value(value[key])
|
|
145
|
+
parts.append(f"{key_str}:{val_str}")
|
|
146
|
+
|
|
147
|
+
return "{" + ",".join(parts) + "}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _serialize_list(value: Union[list, tuple]) -> str:
|
|
151
|
+
"""Serialize a list/tuple to canonical JSON (order preserved)."""
|
|
152
|
+
parts = [_serialize_value(item) for item in value]
|
|
153
|
+
return "[" + ",".join(parts) + "]"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _serialize_value(value: Any) -> str:
|
|
157
|
+
"""Serialize a value to canonical JSON string."""
|
|
158
|
+
if value is None:
|
|
159
|
+
return "null"
|
|
160
|
+
|
|
161
|
+
if isinstance(value, bool):
|
|
162
|
+
return "true" if value else "false"
|
|
163
|
+
|
|
164
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
165
|
+
return str(value)
|
|
166
|
+
|
|
167
|
+
if isinstance(value, float):
|
|
168
|
+
return _normalize_float(value)
|
|
169
|
+
|
|
170
|
+
if isinstance(value, str):
|
|
171
|
+
return _serialize_string(value)
|
|
172
|
+
|
|
173
|
+
if isinstance(value, dict):
|
|
174
|
+
return _serialize_dict(value)
|
|
175
|
+
|
|
176
|
+
if isinstance(value, (list, tuple)):
|
|
177
|
+
return _serialize_list(value)
|
|
178
|
+
|
|
179
|
+
raise CanonicalizeError(
|
|
180
|
+
f"Cannot canonicalize value of type {type(value).__name__}: {value!r}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def canonicalize(obj: Any) -> str:
|
|
185
|
+
"""
|
|
186
|
+
Canonicalize an object to a deterministic JSON string.
|
|
187
|
+
|
|
188
|
+
Guarantees:
|
|
189
|
+
- Dict keys sorted lexicographically
|
|
190
|
+
- Lists preserve order
|
|
191
|
+
- UTF-8 compatible (ensure_ascii=False)
|
|
192
|
+
- No NaN, Infinity, -Infinity
|
|
193
|
+
- Floats normalized: no exponent, no trailing zeros
|
|
194
|
+
- Input is never mutated
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
obj: Any JSON-serializable Python object
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Canonical JSON string
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
CanonicalizeError: If canonicalization fails
|
|
204
|
+
"""
|
|
205
|
+
obj_copy = copy.deepcopy(obj)
|
|
206
|
+
return _serialize_value(obj_copy)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def canonicalize_action_payload(payload: dict) -> bytes:
|
|
210
|
+
"""
|
|
211
|
+
Canonicalize an action payload for hashing.
|
|
212
|
+
|
|
213
|
+
Drops ephemeral/internal fields (id, approval_token, timestamps, etc.)
|
|
214
|
+
and produces canonical bytes.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
payload: Action payload dict with agent_id, tool, operation, params, context
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Canonical bytes representation
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
CanonicalizeError: If canonicalization fails
|
|
224
|
+
"""
|
|
225
|
+
clean_payload: Dict[str, Any] = {}
|
|
226
|
+
|
|
227
|
+
for key, value in payload.items():
|
|
228
|
+
if key in _ACTION_EXCLUDE_FIELDS or key.startswith("_"):
|
|
229
|
+
continue
|
|
230
|
+
clean_payload[key] = value
|
|
231
|
+
|
|
232
|
+
return canonicalize(clean_payload).encode("utf-8")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def compute_request_hash(payload: dict) -> str:
|
|
236
|
+
"""
|
|
237
|
+
Compute SHA-256 hash of canonicalized action payload.
|
|
238
|
+
|
|
239
|
+
This produces the same hash as the Faramesh server, enabling
|
|
240
|
+
clients to verify request_hash matches before/after submission.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
payload: Action payload dict (agent_id, tool, operation, params, context)
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
SHA-256 hex digest (64 characters)
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
CanonicalizeError: If canonicalization fails
|
|
250
|
+
|
|
251
|
+
Example:
|
|
252
|
+
>>> payload = {
|
|
253
|
+
... "agent_id": "test",
|
|
254
|
+
... "tool": "http",
|
|
255
|
+
... "operation": "get",
|
|
256
|
+
... "params": {"url": "https://example.com"},
|
|
257
|
+
... "context": {}
|
|
258
|
+
... }
|
|
259
|
+
>>> hash_value = compute_request_hash(payload)
|
|
260
|
+
>>> print(hash_value) # 64-char hex string
|
|
261
|
+
"""
|
|
262
|
+
canonical_bytes = canonicalize_action_payload(payload)
|
|
263
|
+
return hashlib.sha256(canonical_bytes).hexdigest()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def compute_hash(obj: Any) -> str:
|
|
267
|
+
"""
|
|
268
|
+
Compute SHA-256 hash of the canonical JSON representation.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
obj: Any JSON-serializable Python object
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
SHA-256 hex digest (64 characters)
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
CanonicalizeError: If canonicalization fails
|
|
278
|
+
"""
|
|
279
|
+
canonical_str = canonicalize(obj)
|
|
280
|
+
canonical_bytes = canonical_str.encode("utf-8")
|
|
281
|
+
return hashlib.sha256(canonical_bytes).hexdigest()
|