safentic 1.0.4__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 CHANGED
@@ -1,8 +1,8 @@
1
- from .layer import SafetyLayer, SafenticError
2
-
3
- __all__ = [
4
- "SafetyLayer",
5
- "SafenticError",
6
- ]
7
-
8
- __version__ = "1.0.4"
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
- class PolicyEnforcer():
5
+
6
+ class PolicyEnforcer:
5
7
  """
6
8
  Runtime wrapper to evaluate and enforce tool usage policies.
7
- Tracks agent-specific violations and supports audit logging.
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": set(),
25
+ "blocked_tools": {}, # tool_name -> timestamp of block
22
26
  "violation_count": 0,
23
27
  "last_violation": None
24
28
  })
25
29
 
26
- # Block repeat attempts to use already-denied tool
27
- if tool_name in state["blocked_tools"]:
28
- reason = "Tool previously blocked for this agent."
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
- # Run policy evaluation
36
+ # Evaluate policy
38
37
  violation = self.policy_engine.evaluate_policy(tool_name, tool_args)
39
38
 
40
39
  if violation:
41
- state["blocked_tools"].add(tool_name)
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
- agent_id=agent_id,
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,21 +2,33 @@ 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
 
10
+
9
11
  class InvalidAPIKeyError(Exception):
10
12
  """Raised when an invalid API key is used."""
11
13
  pass
12
14
 
15
+
16
+ class InvalidAgentInterfaceError(Exception):
17
+ """Raised when the wrapped agent does not implement the required method."""
18
+ pass
19
+
20
+
13
21
  class SafetyLayer:
14
22
  """
15
- Safentic runtime enforcement wrapper for agent actions.
16
- Requires a valid API key to function.
23
+ Wraps an agent with real-time enforcement of Safentic policies.
24
+ All tool calls must go through `call_tool()`.
25
+
26
+ Example:
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: str = "", agent_id: str = "", enforcer: PolicyEnforcer = None, raise_on_block: bool = True):
31
+ def __init__(self, agent, api_key: str, agent_id: str = "", enforcer: PolicyEnforcer = None, raise_on_block: bool = True):
20
32
  if not api_key:
21
33
  raise InvalidAPIKeyError("Missing API key")
22
34
 
@@ -24,23 +36,24 @@ class SafetyLayer:
24
36
  if not validation_response or validation_response.get("status") != "valid":
25
37
  raise InvalidAPIKeyError("Invalid or unauthorized API key")
26
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
27
43
  self.api_key = api_key
28
44
  self.agent_id = agent_id
29
45
  self.raise_on_block = raise_on_block
30
46
  self.logger = AuditLogger()
31
-
32
47
  self.enforcer = enforcer or PolicyEnforcer()
33
48
  self.enforcer.reset(agent_id)
34
49
 
35
- def protect(self, tool_name: str, tool_args: dict) -> dict:
50
+ def call_tool(self, tool_name: str, tool_args: dict) -> dict:
36
51
  """
37
- Checks whether a tool action is allowed.
38
- Raises SafenticError if blocked (default), or returns result if raise_on_block=False.
52
+ Intercepts a tool call and enforces policies before execution.
53
+ If blocked, raises `SafenticError` or returns an error response (configurable).
39
54
  """
40
-
41
55
  result = self.enforcer.enforce(self.agent_id, tool_name, tool_args)
42
56
 
43
- # Log structured event
44
57
  self.logger.log(
45
58
  agent_id=self.agent_id,
46
59
  tool=tool_name,
@@ -51,6 +64,6 @@ class SafetyLayer:
51
64
  if not result["allowed"]:
52
65
  if self.raise_on_block:
53
66
  raise SafenticError(result["reason"])
54
- return result
67
+ return {"error": result["reason"]}
55
68
 
56
- return result
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 given tool action complies with safety policies.
11
- Uses rule types such as deny_phrase and semantic checks.
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(self, tool_name: str, args: dict, agent_id: str = "unknown") -> Optional[str]:
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 None if allowed, or a string reason if blocked.
43
- Supports modular rule types per tool.
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 in policy: '{rule_type}' for tool: '{tool_name}'"
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"Blocked: matched deny phrase “{phrase}”"
70
- self.audit_logger.log(
71
- agent_id=agent_id,
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"Blocked by semantic check: {explanation}"
91
- self.audit_logger.log(
92
- agent_id=agent_id,
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: safentic
3
- Version: 1.0.4
3
+ Version: 1.0.5
4
4
  Summary: Safentic SDK for behavior analysis
5
5
  Home-page: https://safentic.com
6
6
  Author: Safentic
@@ -1,9 +1,9 @@
1
1
  safentic/LICENSE.txt,sha256=xl3AZ2rkiOG5qE01SPRBgoW5Ib5YKZQeszh6OlvKePk,2330
2
- safentic/__init__.py,sha256=ZCchAM13KAYuwaA570PTLCuwP4956ov_oZVTOxKy99E,132
2
+ safentic/__init__.py,sha256=RFKBKMmJGUZy436bF_wI0AAgiyWYJqeF3MIlhUi7_VU,124
3
3
  safentic/config.py,sha256=V6c8Fz0t-Ja278kjCrQMlGPBQ4Hj830t3q7U7oM4Q4k,90
4
- safentic/engine.py,sha256=-a90x70SY15WkOIkgxoPVLs_9xGsf4Krj-CmpoMs6tE,2597
5
- safentic/layer.py,sha256=hU6BTjvIbCSPLQrCnfEs2KKCR3JeOzIdWxCIlffk-5c,1876
6
- safentic/policy.py,sha256=ApAAAxiWb_M5TUTtYKk10BVWEy4xViSbM8ikocIqWoI,4111
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=oKq-vbo_THyoupx1r7aoNqIAb72rH2t9uyUJtpbDOyQ,5642
19
- safentic-1.0.4.dist-info/METADATA,sha256=rYcmXmYpFcFakpSGI9QSQkxhDvk2GH8vxkoCgKto590,1477
20
- safentic-1.0.4.dist-info/WHEEL,sha256=0CuiUZ_p9E4cD6NyLD6UG80LBXYyiSYZOKDm5lp32xk,91
21
- safentic-1.0.4.dist-info/top_level.txt,sha256=8bLyq7i9we7XEcbDYLHTUfR3IIpICeMfG8NWAHCLU5s,15
22
- safentic-1.0.4.dist-info/RECORD,,
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
- reason = policy_engine.evaluate_policy("send_email", {"body": TESTS[case]})
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 "previously blocked" in repeat["reason"].lower()
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.protect("send_email", {"body": TESTS["made_up_timeframe"]})
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.protect("send_email", safe_input)["allowed"]
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):