safentic 1.0.3__py3-none-any.whl → 1.0.5__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.
- safentic/__init__.py +8 -8
- safentic/engine.py +43 -31
- safentic/layer.py +35 -16
- safentic/policy.py +26 -30
- {safentic-1.0.3.dist-info → safentic-1.0.5.dist-info}/METADATA +1 -1
- {safentic-1.0.3.dist-info → safentic-1.0.5.dist-info}/RECORD +9 -9
- tests/test_all.py +13 -8
- {safentic-1.0.3.dist-info → safentic-1.0.5.dist-info}/WHEEL +0 -0
- {safentic-1.0.3.dist-info → safentic-1.0.5.dist-info}/top_level.txt +0 -0
safentic/__init__.py
CHANGED
@@ -1,8 +1,8 @@
|
|
1
|
-
from .layer import SafetyLayer, SafenticError
|
2
|
-
|
3
|
-
__all__ = [
|
4
|
-
"SafetyLayer",
|
5
|
-
"SafenticError",
|
6
|
-
]
|
7
|
-
|
8
|
-
__version__ = "1.0.
|
1
|
+
from .layer import SafetyLayer, SafenticError
|
2
|
+
|
3
|
+
__all__ = [
|
4
|
+
"SafetyLayer",
|
5
|
+
"SafenticError",
|
6
|
+
]
|
7
|
+
|
8
|
+
__version__ = "1.0.5"
|
safentic/engine.py
CHANGED
@@ -1,12 +1,16 @@
|
|
1
|
+
import time
|
1
2
|
from .policy import PolicyEngine
|
2
3
|
from .logger.audit import AuditLogger
|
3
4
|
|
4
|
-
|
5
|
+
|
6
|
+
class PolicyEnforcer:
|
5
7
|
"""
|
6
8
|
Runtime wrapper to evaluate and enforce tool usage policies.
|
7
|
-
Tracks agent-specific violations
|
9
|
+
Tracks agent-specific violations, supports audit logging, and handles TTL-based tool blocks.
|
8
10
|
"""
|
9
11
|
|
12
|
+
TOOL_BLOCK_TTL = 60 # seconds - how long a tool remains blocked after violation
|
13
|
+
|
10
14
|
def __init__(self, policy_engine: PolicyEngine = None):
|
11
15
|
self.policy_engine = policy_engine or PolicyEngine()
|
12
16
|
self.agent_states = {}
|
@@ -18,44 +22,43 @@ class PolicyEnforcer():
|
|
18
22
|
Returns a dict with 'allowed', 'reason', and agent state metadata.
|
19
23
|
"""
|
20
24
|
state = self.agent_states.setdefault(agent_id, {
|
21
|
-
"blocked_tools":
|
25
|
+
"blocked_tools": {}, # tool_name -> timestamp of block
|
22
26
|
"violation_count": 0,
|
23
27
|
"last_violation": None
|
24
28
|
})
|
25
29
|
|
26
|
-
#
|
27
|
-
if tool_name
|
28
|
-
reason = "Tool
|
29
|
-
self.audit_logger.log(
|
30
|
-
agent_id=agent_id,
|
31
|
-
tool=tool_name,
|
32
|
-
allowed=False,
|
33
|
-
reason=reason
|
34
|
-
)
|
30
|
+
# Check if tool is still blocked
|
31
|
+
if self._is_tool_blocked(tool_name, state):
|
32
|
+
reason = "Tool is temporarily blocked due to a prior violation."
|
33
|
+
self.audit_logger.log(agent_id=agent_id, tool=tool_name, allowed=False, reason=reason)
|
35
34
|
return self._deny(tool_name, state, reason)
|
36
35
|
|
37
|
-
#
|
36
|
+
# Evaluate policy
|
38
37
|
violation = self.policy_engine.evaluate_policy(tool_name, tool_args)
|
39
38
|
|
40
39
|
if violation:
|
41
|
-
|
40
|
+
# Example violation object: {"reason": "...", "level": "block"}
|
41
|
+
level = violation.get("level", "block")
|
42
|
+
reason = violation.get("reason", "Policy violation")
|
43
|
+
|
44
|
+
if level == "warn":
|
45
|
+
# Log a warning but allow the call
|
46
|
+
self.audit_logger.log(agent_id=agent_id, tool=tool_name, allowed=True, reason=f"Warning: {reason}")
|
47
|
+
return {
|
48
|
+
"allowed": True,
|
49
|
+
"reason": f"Warning: {reason}",
|
50
|
+
"agent_state": state
|
51
|
+
}
|
52
|
+
|
53
|
+
# Otherwise: enforce block
|
54
|
+
state["blocked_tools"][tool_name] = time.time()
|
42
55
|
state["violation_count"] += 1
|
43
56
|
state["last_violation"] = violation
|
44
|
-
self.audit_logger.log(
|
45
|
-
|
46
|
-
tool=tool_name,
|
47
|
-
allowed=False,
|
48
|
-
reason=violation
|
49
|
-
)
|
50
|
-
return self._deny(tool_name, state, violation)
|
51
|
-
|
52
|
-
# Log allowed action
|
53
|
-
self.audit_logger.log(
|
54
|
-
agent_id=agent_id,
|
55
|
-
tool=tool_name,
|
56
|
-
allowed=True
|
57
|
-
)
|
57
|
+
self.audit_logger.log(agent_id=agent_id, tool=tool_name, allowed=False, reason=reason)
|
58
|
+
return self._deny(tool_name, state, reason)
|
58
59
|
|
60
|
+
# Allow
|
61
|
+
self.audit_logger.log(agent_id=agent_id, tool=tool_name, allowed=True)
|
59
62
|
return {
|
60
63
|
"allowed": True,
|
61
64
|
"reason": "Action permitted",
|
@@ -63,9 +66,7 @@ class PolicyEnforcer():
|
|
63
66
|
}
|
64
67
|
|
65
68
|
def reset(self, agent_id: str = None):
|
66
|
-
"""
|
67
|
-
Clears violation state for one agent or all agents.
|
68
|
-
"""
|
69
|
+
"""Clears violation state for one or all agents."""
|
69
70
|
if agent_id:
|
70
71
|
self.agent_states.pop(agent_id, None)
|
71
72
|
else:
|
@@ -78,3 +79,14 @@ class PolicyEnforcer():
|
|
78
79
|
"tool": tool_name,
|
79
80
|
"agent_state": state
|
80
81
|
}
|
82
|
+
|
83
|
+
def _is_tool_blocked(self, tool_name: str, state: dict) -> bool:
|
84
|
+
"""Checks if a tool is still blocked based on TTL."""
|
85
|
+
blocked_at = state["blocked_tools"].get(tool_name)
|
86
|
+
if not blocked_at:
|
87
|
+
return False
|
88
|
+
if time.time() - blocked_at > self.TOOL_BLOCK_TTL:
|
89
|
+
# Tool block expired
|
90
|
+
del state["blocked_tools"][tool_name]
|
91
|
+
return False
|
92
|
+
return True
|
safentic/layer.py
CHANGED
@@ -2,38 +2,58 @@ from .engine import PolicyEnforcer
|
|
2
2
|
from .logger.audit import AuditLogger
|
3
3
|
from .helper.auth import validate_api_key
|
4
4
|
|
5
|
+
|
5
6
|
class SafenticError(Exception):
|
6
7
|
"""Raised when Safentic blocks an action."""
|
7
8
|
pass
|
8
9
|
|
9
10
|
|
10
|
-
class
|
11
|
+
class InvalidAPIKeyError(Exception):
|
12
|
+
"""Raised when an invalid API key is used."""
|
13
|
+
pass
|
14
|
+
|
15
|
+
|
16
|
+
class InvalidAgentInterfaceError(Exception):
|
17
|
+
"""Raised when the wrapped agent does not implement the required method."""
|
18
|
+
pass
|
19
|
+
|
20
|
+
|
21
|
+
class SafetyLayer:
|
11
22
|
"""
|
12
|
-
|
13
|
-
|
23
|
+
Wraps an agent with real-time enforcement of Safentic policies.
|
24
|
+
All tool calls must go through `call_tool()`.
|
25
|
+
|
14
26
|
Example:
|
15
|
-
|
16
|
-
|
27
|
+
agent = SafetyLayer(MyAgent(), api_key="...", agent_id="agent-001")
|
28
|
+
agent.call_tool("send_email", {"to": "alice@example.com"})
|
17
29
|
"""
|
18
30
|
|
19
|
-
def __init__(self, api_key
|
31
|
+
def __init__(self, agent, api_key: str, agent_id: str = "", enforcer: PolicyEnforcer = None, raise_on_block: bool = True):
|
32
|
+
if not api_key:
|
33
|
+
raise InvalidAPIKeyError("Missing API key")
|
34
|
+
|
35
|
+
validation_response = validate_api_key(api_key)
|
36
|
+
if not validation_response or validation_response.get("status") != "valid":
|
37
|
+
raise InvalidAPIKeyError("Invalid or unauthorized API key")
|
38
|
+
|
39
|
+
if not hasattr(agent, "call_tool") or not callable(getattr(agent, "call_tool")):
|
40
|
+
raise InvalidAgentInterfaceError("Wrapped agent must implement `call_tool(tool_name: str, **kwargs)`")
|
41
|
+
|
42
|
+
self.agent = agent
|
43
|
+
self.api_key = api_key
|
20
44
|
self.agent_id = agent_id
|
21
45
|
self.raise_on_block = raise_on_block
|
22
46
|
self.logger = AuditLogger()
|
23
|
-
|
24
47
|
self.enforcer = enforcer or PolicyEnforcer()
|
25
|
-
self.api_key = validate_api_key(api_key)
|
26
48
|
self.enforcer.reset(agent_id)
|
27
49
|
|
28
|
-
def
|
50
|
+
def call_tool(self, tool_name: str, tool_args: dict) -> dict:
|
29
51
|
"""
|
30
|
-
|
31
|
-
|
52
|
+
Intercepts a tool call and enforces policies before execution.
|
53
|
+
If blocked, raises `SafenticError` or returns an error response (configurable).
|
32
54
|
"""
|
33
|
-
|
34
55
|
result = self.enforcer.enforce(self.agent_id, tool_name, tool_args)
|
35
56
|
|
36
|
-
# Log structured event
|
37
57
|
self.logger.log(
|
38
58
|
agent_id=self.agent_id,
|
39
59
|
tool=tool_name,
|
@@ -41,10 +61,9 @@ class SafetyLayer():
|
|
41
61
|
reason=result["reason"] if not result["allowed"] else None
|
42
62
|
)
|
43
63
|
|
44
|
-
# Raise or return based on outcome and config
|
45
64
|
if not result["allowed"]:
|
46
65
|
if self.raise_on_block:
|
47
66
|
raise SafenticError(result["reason"])
|
48
|
-
return result
|
67
|
+
return {"error": result["reason"]}
|
49
68
|
|
50
|
-
return
|
69
|
+
return self.agent.call_tool(tool_name, **tool_args)
|
safentic/policy.py
CHANGED
@@ -1,14 +1,15 @@
|
|
1
1
|
import os
|
2
2
|
import yaml
|
3
|
-
from typing import Optional
|
3
|
+
from typing import Optional, Dict, Any, Union
|
4
4
|
from .verifiers.sentence_verifier import SentenceTransformerVerifier
|
5
5
|
from .logger.audit import AuditLogger
|
6
6
|
|
7
7
|
|
8
8
|
class PolicyEngine:
|
9
9
|
"""
|
10
|
-
Evaluates whether a
|
11
|
-
|
10
|
+
Evaluates whether a tool action complies with safety policies.
|
11
|
+
Supports multiple rule types: deny_phrase, semantic.
|
12
|
+
Returns structured violations for downstream enforcement.
|
12
13
|
"""
|
13
14
|
|
14
15
|
VALID_RULE_TYPES = {"deny_phrase", "semantic"}
|
@@ -37,10 +38,16 @@ class PolicyEngine:
|
|
37
38
|
with open(path, encoding="utf-8") as f:
|
38
39
|
return f.read().strip().lower()
|
39
40
|
|
40
|
-
def evaluate_policy(
|
41
|
+
def evaluate_policy(
|
42
|
+
self,
|
43
|
+
tool_name: str,
|
44
|
+
args: Dict[str, Any],
|
45
|
+
agent_id: str = "unknown"
|
46
|
+
) -> Optional[Dict[str, Union[str, Any]]]:
|
41
47
|
"""
|
42
|
-
Returns
|
43
|
-
|
48
|
+
Returns:
|
49
|
+
None if allowed,
|
50
|
+
dict with 'reason' and 'level' if blocked or warned
|
44
51
|
"""
|
45
52
|
tool_rules = self.policy_cfg.get("tools", {}).get(tool_name)
|
46
53
|
if not tool_rules:
|
@@ -52,50 +59,39 @@ class PolicyEngine:
|
|
52
59
|
|
53
60
|
for check in tool_rules.get("checks", []):
|
54
61
|
rule_type = check.get("type")
|
62
|
+
level = check.get("level", "block") # Default to block
|
55
63
|
|
56
64
|
if rule_type not in self.VALID_RULE_TYPES:
|
57
|
-
warning = f"Unknown rule type
|
58
|
-
self.audit_logger.log(
|
59
|
-
agent_id=agent_id,
|
60
|
-
tool=tool_name,
|
61
|
-
allowed=True,
|
62
|
-
reason=warning
|
63
|
-
)
|
65
|
+
warning = f"Unknown rule type: '{rule_type}' for tool: '{tool_name}'"
|
66
|
+
self.audit_logger.log(agent_id=agent_id, tool=tool_name, allowed=True, reason=warning)
|
64
67
|
continue
|
65
68
|
|
69
|
+
# ---- Phrase Matching ----
|
66
70
|
if rule_type == "deny_phrase":
|
67
71
|
for phrase in check.get("phrases", []):
|
68
72
|
if phrase.lower() in text:
|
69
|
-
reason = f"
|
70
|
-
self.audit_logger.log(
|
71
|
-
|
72
|
-
tool=tool_name,
|
73
|
-
allowed=False,
|
74
|
-
reason=reason
|
75
|
-
)
|
76
|
-
return reason
|
73
|
+
reason = f"Matched deny phrase: “{phrase}”"
|
74
|
+
self.audit_logger.log(agent_id=agent_id, tool=tool_name, allowed=(level == "warn"), reason=reason)
|
75
|
+
return {"reason": reason, "level": level}
|
77
76
|
|
77
|
+
# ---- Semantic Check ----
|
78
78
|
elif rule_type == "semantic":
|
79
79
|
trigger_phrases = [p.lower() for p in check.get("trigger_phrases", [])]
|
80
80
|
if any(p in text for p in trigger_phrases):
|
81
81
|
reference_file = check.get("reference_file")
|
82
82
|
if not reference_file:
|
83
|
-
continue
|
83
|
+
continue # Skip if not configured
|
84
84
|
|
85
85
|
reference_text = self._load_reference_text(reference_file)
|
86
86
|
decision = self.verifier.decision(candidate=text, official=reference_text)
|
87
87
|
|
88
88
|
if decision == "block":
|
89
89
|
explanation = self.verifier.explain(candidate=text, official=reference_text)
|
90
|
-
reason = f"
|
91
|
-
self.audit_logger.log(
|
92
|
-
|
93
|
-
tool=tool_name,
|
94
|
-
allowed=False,
|
95
|
-
reason=reason
|
96
|
-
)
|
97
|
-
return reason
|
90
|
+
reason = f"Semantic block: {explanation}"
|
91
|
+
self.audit_logger.log(agent_id=agent_id, tool=tool_name, allowed=(level == "warn"), reason=reason)
|
92
|
+
return {"reason": reason, "level": level}
|
98
93
|
|
94
|
+
# Log semantic pass
|
99
95
|
self.audit_logger.log(
|
100
96
|
agent_id=agent_id,
|
101
97
|
tool=tool_name,
|
@@ -1,9 +1,9 @@
|
|
1
1
|
safentic/LICENSE.txt,sha256=xl3AZ2rkiOG5qE01SPRBgoW5Ib5YKZQeszh6OlvKePk,2330
|
2
|
-
safentic/__init__.py,sha256=
|
2
|
+
safentic/__init__.py,sha256=RFKBKMmJGUZy436bF_wI0AAgiyWYJqeF3MIlhUi7_VU,124
|
3
3
|
safentic/config.py,sha256=V6c8Fz0t-Ja278kjCrQMlGPBQ4Hj830t3q7U7oM4Q4k,90
|
4
|
-
safentic/engine.py,sha256
|
5
|
-
safentic/layer.py,sha256=
|
6
|
-
safentic/policy.py,sha256=
|
4
|
+
safentic/engine.py,sha256=Ho9-sHXnJuRUh0fsONLip8Dsf927EJK_8RwXDk4Mk-Q,3564
|
5
|
+
safentic/layer.py,sha256=3sGVn7gGWoiStEKO3jTV4_2QxKUWEH1BiThR421GISo,2481
|
6
|
+
safentic/policy.py,sha256=VKIxLfcJtwA3U7DVwefLWprKQCFbfya-RJZvU1n4sEc,4138
|
7
7
|
safentic/helper/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
8
8
|
safentic/helper/auth.py,sha256=evhikKRTtGXYp0YKkdntBM4bUjdUTtvJ2nA5YLt2IIA,391
|
9
9
|
safentic/logger/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
@@ -15,8 +15,8 @@ safentic/policies/policy.yaml,sha256=EoDjvloG56WGNjqNV18xytcCr2v4I0KG6uSx6_yTVAg
|
|
15
15
|
safentic/verifiers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
16
16
|
safentic/verifiers/sentence_verifier.py,sha256=jgObZ1t4UCYHYIfNayI0n69hVBWOHc9GAhpJsDbiD2c,2376
|
17
17
|
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
18
|
-
tests/test_all.py,sha256=
|
19
|
-
safentic-1.0.
|
20
|
-
safentic-1.0.
|
21
|
-
safentic-1.0.
|
22
|
-
safentic-1.0.
|
18
|
+
tests/test_all.py,sha256=makysLQtgKAFOfIutnvmBQcMLkMY3inkT6ig5h_I2e4,5878
|
19
|
+
safentic-1.0.5.dist-info/METADATA,sha256=5S7U-KmaCPDVPcv_HsR7K6Px0inqySRxRr_fbqdxJv4,1477
|
20
|
+
safentic-1.0.5.dist-info/WHEEL,sha256=0CuiUZ_p9E4cD6NyLD6UG80LBXYyiSYZOKDm5lp32xk,91
|
21
|
+
safentic-1.0.5.dist-info/top_level.txt,sha256=8bLyq7i9we7XEcbDYLHTUfR3IIpICeMfG8NWAHCLU5s,15
|
22
|
+
safentic-1.0.5.dist-info/RECORD,,
|
tests/test_all.py
CHANGED
@@ -18,6 +18,10 @@ TESTS = {
|
|
18
18
|
"generic_refund": "I’m sorry for the trouble—please let us know more about your refund issue.",
|
19
19
|
}
|
20
20
|
|
21
|
+
class MockAgent:
|
22
|
+
def call_tool(self, tool_name, **kwargs):
|
23
|
+
return {"status": "mock-success"}
|
24
|
+
|
21
25
|
@pytest.fixture
|
22
26
|
def policy_engine():
|
23
27
|
return PolicyEngine()
|
@@ -33,8 +37,8 @@ def enforcer():
|
|
33
37
|
("made_up_timeframe", "24 hours"),
|
34
38
|
])
|
35
39
|
def test_policy_engine_evaluate(policy_engine, case, expected_contains):
|
36
|
-
|
37
|
-
assert expected_contains in reason.lower()
|
40
|
+
violation = policy_engine.evaluate_policy("send_email", {"body": TESTS[case]})
|
41
|
+
assert violation and expected_contains in violation["reason"].lower()
|
38
42
|
|
39
43
|
def test_policy_engine_skips_empty(policy_engine):
|
40
44
|
assert policy_engine.evaluate_policy("send_email", {"body": ""}) is None
|
@@ -47,7 +51,7 @@ def test_policy_engine_unknown_rule_type(policy_engine):
|
|
47
51
|
|
48
52
|
def test_policy_engine_malformed_semantic(policy_engine):
|
49
53
|
policy_engine.policy_cfg = {
|
50
|
-
"tools": {"send_email": {"checks": [{"type": "semantic", "trigger_phrases": ["refund"]}]}}
|
54
|
+
"tools": {"send_email": {"checks": [{"type": "semantic", "trigger_phrases": ["refund"]}]}},
|
51
55
|
}
|
52
56
|
assert policy_engine.evaluate_policy("send_email", {"body": "refund policy applies"}) is None
|
53
57
|
|
@@ -56,25 +60,26 @@ def test_enforcer_blocks_expected_cases(enforcer):
|
|
56
60
|
for case in ["valid_excerpt", "generic_refund"]:
|
57
61
|
result = enforcer.enforce(agent_id, "send_email", {"body": TESTS[case]})
|
58
62
|
assert not result["allowed"]
|
63
|
+
assert "reason" in result
|
59
64
|
|
60
65
|
def test_enforcer_blocks_and_resets(enforcer):
|
61
66
|
agent_id = "agent-reset"
|
62
67
|
res = enforcer.enforce(agent_id, "send_email", {"body": TESTS["hallucination_one_device"]})
|
63
68
|
assert not res["allowed"]
|
64
69
|
repeat = enforcer.enforce(agent_id, "send_email", {"body": TESTS["hallucination_one_device"]})
|
65
|
-
assert "
|
70
|
+
assert "temporarily blocked" in repeat["reason"].lower()
|
66
71
|
enforcer.reset(agent_id)
|
67
72
|
assert agent_id not in enforcer.agent_states
|
68
73
|
|
69
74
|
def test_safety_layer_blocks_and_raises():
|
70
|
-
layer = SafetyLayer(api_key="demo-1234", agent_id="safety-1", raise_on_block=True)
|
75
|
+
layer = SafetyLayer(agent=MockAgent(), api_key="demo-1234", agent_id="safety-1", raise_on_block=True)
|
71
76
|
with pytest.raises(SafenticError):
|
72
|
-
layer.
|
77
|
+
layer.call_tool("send_email", {"body": TESTS["made_up_timeframe"]})
|
73
78
|
|
74
79
|
def test_safety_layer_returns_result():
|
75
80
|
safe_input = {"body": "This is a neutral and policy-safe email message."}
|
76
|
-
layer = SafetyLayer(api_key="demo-1234", agent_id="safety-2", raise_on_block=False)
|
77
|
-
assert layer.
|
81
|
+
layer = SafetyLayer(agent=MockAgent(), api_key="demo-1234", agent_id="safety-2", raise_on_block=False)
|
82
|
+
assert layer.call_tool("send_email", safe_input)["status"] == "mock-success"
|
78
83
|
|
79
84
|
@mock.patch("safentic.verifiers.sentence_verifier.SentenceTransformer")
|
80
85
|
def test_similarity_score_consistency(mock_model_class):
|
File without changes
|
File without changes
|