safentic 1.0.5__py3-none-any.whl → 1.0.6__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 +4 -7
- safentic/_internal/errors.py +26 -0
- safentic/adapters/mcp_adapter.py +46 -0
- safentic/cli/__init__.py +3 -0
- safentic/cli/commands/check_tool.py +47 -0
- safentic/cli/commands/logs.py +66 -0
- safentic/cli/commands/validate_policy.py +59 -0
- safentic/cli/main.py +153 -0
- safentic/cli/utils.py +169 -0
- safentic/config.py +2 -2
- safentic/decorators.py +49 -0
- safentic/helper/helper.py +212 -0
- safentic/layer.py +96 -69
- safentic/logger/audit.py +181 -83
- safentic/policy_enforcer.py +116 -0
- safentic/policy_engine.py +141 -0
- safentic/verifiers/llm_verifier.py +238 -0
- safentic-1.0.6.dist-info/METADATA +193 -0
- safentic-1.0.6.dist-info/RECORD +29 -0
- {safentic-1.0.5.dist-info → safentic-1.0.6.dist-info}/WHEEL +1 -1
- safentic-1.0.6.dist-info/entry_points.txt +2 -0
- {safentic → safentic-1.0.6.dist-info/licenses}/LICENSE.txt +36 -36
- safentic-1.0.6.dist-info/top_level.txt +2 -0
- safentic_poc/backend/api/main.py +164 -0
- safentic/engine.py +0 -92
- safentic/helper/auth.py +0 -12
- safentic/policies/__init__.py +0 -3
- safentic/policies/example_policy.txt +0 -33
- safentic/policies/policy.yaml +0 -49
- safentic/policy.py +0 -102
- safentic/verifiers/sentence_verifier.py +0 -69
- safentic-1.0.5.dist-info/METADATA +0 -60
- safentic-1.0.5.dist-info/RECORD +0 -22
- safentic-1.0.5.dist-info/top_level.txt +0 -2
- tests/test_all.py +0 -132
- {tests → safentic_poc/backend}/__init__.py +0 -0
- /safentic/policies/.gitkeep → /safentic_poc/backend/api/__init__.py +0 -0
@@ -0,0 +1,212 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import json
|
4
|
+
import os
|
5
|
+
import re
|
6
|
+
import time
|
7
|
+
from typing import Any, Dict, Iterable, List, Optional, Union
|
8
|
+
|
9
|
+
import requests
|
10
|
+
from ..config import BASE_API_PATH, API_KEY_ENDPOINT
|
11
|
+
|
12
|
+
from .._internal.errors import PolicyValidationError, ReferenceFileError, VerifierError
|
13
|
+
|
14
|
+
|
15
|
+
def validate_api_key(key: str) -> Dict[str, Any]:
|
16
|
+
try:
|
17
|
+
response = requests.post(
|
18
|
+
BASE_API_PATH + API_KEY_ENDPOINT, json={"api_key": key}
|
19
|
+
)
|
20
|
+
if response.status_code != 200:
|
21
|
+
return {"valid": False}
|
22
|
+
data = response.json()
|
23
|
+
# Ensure dict shape for mypy; if backend returns non-dict, coerce to wrapped data
|
24
|
+
if not isinstance(data, dict):
|
25
|
+
return {"valid": True, "data": data}
|
26
|
+
# Merge as before
|
27
|
+
out: Dict[str, Any] = {"valid": True}
|
28
|
+
out.update(data)
|
29
|
+
return out
|
30
|
+
except Exception:
|
31
|
+
return {"valid": False}
|
32
|
+
|
33
|
+
|
34
|
+
def require(rule: Dict[str, Any], rid: str, key: str) -> None:
|
35
|
+
"""Ensure a rule has a required key, else raise PolicyValidationError."""
|
36
|
+
if not rule.get(key):
|
37
|
+
raise PolicyValidationError(f"{rid}: missing required '{key}'")
|
38
|
+
|
39
|
+
|
40
|
+
def get_text_fields(
|
41
|
+
tool_input: Dict[str, Any],
|
42
|
+
fields: List[str],
|
43
|
+
rid: str,
|
44
|
+
logger: Optional[Any] = None,
|
45
|
+
) -> str:
|
46
|
+
"""Extract text fields from tool input, log missing ones if logger provided."""
|
47
|
+
texts: List[str] = []
|
48
|
+
missing: List[str] = []
|
49
|
+
for f in fields:
|
50
|
+
val = tool_input.get(f)
|
51
|
+
if isinstance(val, str):
|
52
|
+
texts.append(val)
|
53
|
+
else:
|
54
|
+
missing.append(f)
|
55
|
+
|
56
|
+
if missing and logger is not None:
|
57
|
+
# keep original call signature; annotate logger as Any
|
58
|
+
logger.log(
|
59
|
+
agent_id="-",
|
60
|
+
tool="-schema-",
|
61
|
+
allowed=True,
|
62
|
+
reason=f"Missing expected fields for {rid}: {missing}",
|
63
|
+
extra={"event": "missing_fields", "rule": rid, "missing": missing},
|
64
|
+
)
|
65
|
+
return "\n".join(texts).strip()
|
66
|
+
|
67
|
+
|
68
|
+
def deny_response(
|
69
|
+
tool_name: str,
|
70
|
+
state: Dict[str, Any],
|
71
|
+
reason: str,
|
72
|
+
violation: Optional[Dict[str, Any]] = None,
|
73
|
+
) -> Dict[str, Any]:
|
74
|
+
"""Return a standard structured deny response."""
|
75
|
+
return {
|
76
|
+
"allowed": False,
|
77
|
+
"reason": reason,
|
78
|
+
"tool": tool_name,
|
79
|
+
"agent_state": state,
|
80
|
+
"violation": violation or {},
|
81
|
+
}
|
82
|
+
|
83
|
+
|
84
|
+
def is_tool_blocked(tool_name: str, state: Dict[str, Any], ttl: int) -> bool:
|
85
|
+
"""Check if a tool is still blocked based on TTL. Cleans up expired entries."""
|
86
|
+
# Expect state["blocked_tools"] to be a dict[str, float]
|
87
|
+
blocked_tools = state.get("blocked_tools")
|
88
|
+
if not isinstance(blocked_tools, dict):
|
89
|
+
return False
|
90
|
+
blocked_at = blocked_tools.get(tool_name)
|
91
|
+
if not isinstance(blocked_at, (int, float)):
|
92
|
+
return False
|
93
|
+
if time.time() - float(blocked_at) > ttl:
|
94
|
+
# TTL expired → unblock
|
95
|
+
try:
|
96
|
+
del blocked_tools[tool_name]
|
97
|
+
except Exception:
|
98
|
+
pass
|
99
|
+
return False
|
100
|
+
return True
|
101
|
+
|
102
|
+
|
103
|
+
def max_tokens_for_format(fmt: str) -> int:
|
104
|
+
"""Return max tokens allowed for given response format."""
|
105
|
+
if fmt == "boolean":
|
106
|
+
return 5
|
107
|
+
elif fmt == "string":
|
108
|
+
return 50
|
109
|
+
elif fmt == "json":
|
110
|
+
return 80
|
111
|
+
return 40 # default fallback
|
112
|
+
|
113
|
+
|
114
|
+
def check_match(llm_output: str, trigger: str, mode: str) -> bool:
|
115
|
+
"""
|
116
|
+
Determines if the LLM output matches the trigger value.
|
117
|
+
Supports: exact, regex, jsonpath.
|
118
|
+
"""
|
119
|
+
try:
|
120
|
+
if mode == "exact":
|
121
|
+
return llm_output.strip().lower() == trigger.strip().lower()
|
122
|
+
elif mode == "regex":
|
123
|
+
return re.search(trigger, llm_output, re.IGNORECASE) is not None
|
124
|
+
elif mode == "jsonpath":
|
125
|
+
from jsonpath_ng.ext import parse
|
126
|
+
|
127
|
+
parsed = parse(trigger).find(json.loads(llm_output))
|
128
|
+
return bool(parsed)
|
129
|
+
except Exception as e:
|
130
|
+
raise VerifierError(f"Failed during match check: {e}") from e
|
131
|
+
|
132
|
+
return False # unsupported mode
|
133
|
+
|
134
|
+
|
135
|
+
class ReferenceLoader:
|
136
|
+
"""
|
137
|
+
Minimal, predictable reference file resolver with mtime caching.
|
138
|
+
|
139
|
+
Resolution order for a given 'filename':
|
140
|
+
1) absolute path -> use as-is
|
141
|
+
2) policy_dir / filename
|
142
|
+
3) SAFENTIC_REF_BASE / filename (optional single base for monorepos/CI)
|
143
|
+
|
144
|
+
This keeps v1 simple and transparent. You can extend later if needed.
|
145
|
+
"""
|
146
|
+
|
147
|
+
def __init__(self, reference_dir: str):
|
148
|
+
# Treat this as the policy directory (directory of policy.yaml)
|
149
|
+
self.reference_dir: str = os.path.abspath(reference_dir)
|
150
|
+
base_env = os.getenv("SAFENTIC_REF_BASE") # optional single base
|
151
|
+
self.ref_base_env: Optional[str] = (
|
152
|
+
os.path.abspath(os.path.expanduser(base_env)) if base_env else None
|
153
|
+
)
|
154
|
+
|
155
|
+
# mtime cache keyed by absolute path
|
156
|
+
self._ref_cache: Dict[str, Dict[str, Union[float, str]]] = {}
|
157
|
+
|
158
|
+
# --------------- public API ---------------
|
159
|
+
|
160
|
+
def load(self, filename: str) -> str:
|
161
|
+
"""
|
162
|
+
Resolve and load the reference file, caching by mtime.
|
163
|
+
Raises ReferenceFileError with attempted paths when not found or unreadable/empty.
|
164
|
+
"""
|
165
|
+
candidates = list(self._candidate_paths(filename))
|
166
|
+
|
167
|
+
resolved = next((p for p in candidates if os.path.isfile(p)), None)
|
168
|
+
if not resolved:
|
169
|
+
details: Dict[str, Any] = {
|
170
|
+
"requested": filename,
|
171
|
+
"attempted": candidates,
|
172
|
+
"hint": "Use a path relative to your policy file, or set SAFENTIC_REF_BASE to a directory that contains your reference docs.",
|
173
|
+
}
|
174
|
+
raise ReferenceFileError(
|
175
|
+
f"Reference file not found: {filename}. Details: {details}"
|
176
|
+
)
|
177
|
+
|
178
|
+
mtime = os.path.getmtime(resolved)
|
179
|
+
cached = self._ref_cache.get(resolved)
|
180
|
+
if cached and cached.get("mtime") == mtime:
|
181
|
+
# cached["text"] is a str by construction; help mypy with a cast
|
182
|
+
return str(cached.get("text"))
|
183
|
+
|
184
|
+
try:
|
185
|
+
with open(resolved, "r", encoding="utf-8") as f:
|
186
|
+
text = f.read()
|
187
|
+
except Exception as e:
|
188
|
+
raise ReferenceFileError(f"Failed to read reference file {resolved}: {e}")
|
189
|
+
|
190
|
+
if not text.strip():
|
191
|
+
raise ReferenceFileError(f"Reference file is empty: {resolved}")
|
192
|
+
|
193
|
+
self._ref_cache[resolved] = {"mtime": mtime, "text": text}
|
194
|
+
return text
|
195
|
+
|
196
|
+
def clear_cache(self) -> None:
|
197
|
+
self._ref_cache.clear()
|
198
|
+
|
199
|
+
# --------------- internals ---------------
|
200
|
+
|
201
|
+
def _candidate_paths(self, filename: str) -> Iterable[str]:
|
202
|
+
# 1) absolute path
|
203
|
+
if os.path.isabs(filename):
|
204
|
+
yield os.path.abspath(os.path.expanduser(filename))
|
205
|
+
return
|
206
|
+
|
207
|
+
# 2) policy_dir / filename
|
208
|
+
yield os.path.abspath(os.path.join(self.reference_dir, filename))
|
209
|
+
|
210
|
+
# 3) SAFENTIC_REF_BASE / filename (optional)
|
211
|
+
if self.ref_base_env:
|
212
|
+
yield os.path.abspath(os.path.join(self.ref_base_env, filename))
|
safentic/layer.py
CHANGED
@@ -1,69 +1,96 @@
|
|
1
|
-
from
|
2
|
-
|
3
|
-
from
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
1
|
+
from typing import Any, Protocol
|
2
|
+
import os
|
3
|
+
from dotenv import load_dotenv
|
4
|
+
|
5
|
+
from .policy_enforcer import PolicyEnforcer
|
6
|
+
from .logger.audit import AuditLogger
|
7
|
+
from .helper.helper import validate_api_key
|
8
|
+
from .policy_engine import PolicyEngine
|
9
|
+
from ._internal.errors import (
|
10
|
+
SafenticError,
|
11
|
+
InvalidAPIKeyError,
|
12
|
+
InvalidAgentInterfaceError,
|
13
|
+
)
|
14
|
+
|
15
|
+
load_dotenv()
|
16
|
+
|
17
|
+
|
18
|
+
class AgentProtocol(Protocol):
|
19
|
+
"""Minimal interface expected from a wrapped agent."""
|
20
|
+
|
21
|
+
def call_tool(self, tool_name: str, **kwargs: Any) -> dict[str, Any]: ...
|
22
|
+
|
23
|
+
|
24
|
+
class SafetyLayer:
|
25
|
+
"""
|
26
|
+
Developer-facing wrapper that enforces Safentic policies around an agent.
|
27
|
+
All tool calls must go through `call_tool()`.
|
28
|
+
"""
|
29
|
+
|
30
|
+
def __init__(
|
31
|
+
self,
|
32
|
+
agent: AgentProtocol,
|
33
|
+
api_key: str,
|
34
|
+
agent_id: str = "",
|
35
|
+
raise_on_block: bool = True,
|
36
|
+
) -> None:
|
37
|
+
if not api_key:
|
38
|
+
raise InvalidAPIKeyError("Missing API key")
|
39
|
+
|
40
|
+
validation_response = validate_api_key(api_key)
|
41
|
+
if not validation_response or validation_response.get("status") != "valid":
|
42
|
+
raise InvalidAPIKeyError("Invalid or unauthorized API key")
|
43
|
+
|
44
|
+
if not hasattr(agent, "call_tool") or not callable(getattr(agent, "call_tool")):
|
45
|
+
raise InvalidAgentInterfaceError(
|
46
|
+
"Wrapped agent must implement `call_tool(tool_name: str, **kwargs)`"
|
47
|
+
)
|
48
|
+
|
49
|
+
self.agent: AgentProtocol = agent
|
50
|
+
self.api_key: str = api_key
|
51
|
+
self.agent_id: str = agent_id
|
52
|
+
self.raise_on_block: bool = raise_on_block
|
53
|
+
self.logger: AuditLogger = AuditLogger()
|
54
|
+
|
55
|
+
# Decide where policy.yaml lives (env var > default repo path)
|
56
|
+
policy_path = os.getenv(
|
57
|
+
"SAFENTIC_POLICY_PATH",
|
58
|
+
os.path.join(
|
59
|
+
os.path.dirname(__file__), "..", "policy_file_docs", "policy.yaml"
|
60
|
+
),
|
61
|
+
)
|
62
|
+
|
63
|
+
# Build engine and inject API key to verifier
|
64
|
+
engine = PolicyEngine(policy_path=policy_path)
|
65
|
+
engine.llm.set_api_key(api_key)
|
66
|
+
|
67
|
+
# Strict enforcer injection
|
68
|
+
self.enforcer = PolicyEnforcer(policy_engine=engine)
|
69
|
+
self.enforcer.reset(agent_id)
|
70
|
+
|
71
|
+
def call_tool(self, tool_name: str, tool_args: dict[str, Any]) -> dict[str, Any]:
|
72
|
+
"""
|
73
|
+
Intercepts a tool call and enforces policies before execution.
|
74
|
+
If blocked, raises `SafenticError` or returns an error response (configurable).
|
75
|
+
"""
|
76
|
+
result: dict[str, Any] = self.enforcer.enforce(
|
77
|
+
self.agent_id, tool_name, tool_args
|
78
|
+
)
|
79
|
+
|
80
|
+
self.logger.log(
|
81
|
+
agent_id=self.agent_id,
|
82
|
+
tool=tool_name,
|
83
|
+
allowed=result["allowed"],
|
84
|
+
reason=result["reason"] if not result["allowed"] else None,
|
85
|
+
)
|
86
|
+
|
87
|
+
if not result["allowed"]:
|
88
|
+
if self.raise_on_block:
|
89
|
+
raise SafenticError(result["reason"])
|
90
|
+
return {
|
91
|
+
"error": result["reason"],
|
92
|
+
"tool": tool_name,
|
93
|
+
"violation": result.get("violation"),
|
94
|
+
}
|
95
|
+
|
96
|
+
return self.agent.call_tool(tool_name, **tool_args)
|
safentic/logger/audit.py
CHANGED
@@ -1,83 +1,181 @@
|
|
1
|
-
import
|
2
|
-
|
3
|
-
import
|
4
|
-
import
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
"
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
if not
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
"
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
"
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import json
|
4
|
+
import logging
|
5
|
+
import os
|
6
|
+
from datetime import datetime
|
7
|
+
from typing import Any, Dict, Optional
|
8
|
+
|
9
|
+
|
10
|
+
class AuditLogger:
|
11
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
|
12
|
+
"""
|
13
|
+
Minimal, flexible audit logger with text + JSONL outputs.
|
14
|
+
|
15
|
+
Precedence rules
|
16
|
+
----------------
|
17
|
+
Enabled:
|
18
|
+
1) config["enabled"] (bool)
|
19
|
+
2) SAFENTIC_AUDIT_ENABLED env (truthy/falsey; "0","false","no" disable)
|
20
|
+
3) SAFE_AUDIT_LOG env ("0" disables; legacy)
|
21
|
+
4) default: True
|
22
|
+
|
23
|
+
Paths:
|
24
|
+
Text log (human-readable):
|
25
|
+
1) config["destination"]
|
26
|
+
2) SAFENTIC_LOG_PATH env (or SAFE_LOG_PATH legacy)
|
27
|
+
3) default: safentic/logs/txt_logs/safentic_audit.log
|
28
|
+
|
29
|
+
JSONL log (structured):
|
30
|
+
1) config["jsonl"]
|
31
|
+
2) SAFENTIC_JSON_LOG_PATH env (or SAFE_JSON_LOG_PATH legacy)
|
32
|
+
3) default: safentic/logs/json_logs/safentic_audit.jsonl
|
33
|
+
|
34
|
+
Level:
|
35
|
+
1) config["level"]
|
36
|
+
2) SAFENTIC_LOG_LEVEL env
|
37
|
+
3) default: INFO
|
38
|
+
"""
|
39
|
+
cfg: Dict[str, Any] = config or {}
|
40
|
+
|
41
|
+
# --------------------
|
42
|
+
# Enabled flag
|
43
|
+
# --------------------
|
44
|
+
enabled_cfg = cfg.get("enabled")
|
45
|
+
enabled_env = os.getenv("SAFENTIC_AUDIT_ENABLED")
|
46
|
+
|
47
|
+
def _is_truthy(v: str) -> bool:
|
48
|
+
return str(v).strip().lower() not in {"0", "false", "no", "off", ""}
|
49
|
+
|
50
|
+
if enabled_cfg is not None:
|
51
|
+
self.enabled: bool = bool(enabled_cfg)
|
52
|
+
elif enabled_env is not None:
|
53
|
+
self.enabled = _is_truthy(enabled_env)
|
54
|
+
else:
|
55
|
+
# legacy disable switch
|
56
|
+
self.enabled = os.getenv("SAFE_AUDIT_LOG") != "0"
|
57
|
+
|
58
|
+
# default to True if unset by any path
|
59
|
+
if (
|
60
|
+
enabled_cfg is None
|
61
|
+
and enabled_env is None
|
62
|
+
and os.getenv("SAFE_AUDIT_LOG") is None
|
63
|
+
):
|
64
|
+
self.enabled = True
|
65
|
+
|
66
|
+
# --------------------
|
67
|
+
# Paths (with env overrides)
|
68
|
+
# --------------------
|
69
|
+
txt_from_cfg = cfg.get("destination")
|
70
|
+
txt_from_env = os.getenv("SAFENTIC_LOG_PATH") or os.getenv("SAFE_LOG_PATH")
|
71
|
+
self.txt_log_path: str = (
|
72
|
+
txt_from_cfg or txt_from_env or "safentic/logs/txt_logs/safentic_audit.log"
|
73
|
+
)
|
74
|
+
|
75
|
+
json_from_cfg = cfg.get("jsonl")
|
76
|
+
json_from_env = os.getenv("SAFENTIC_JSON_LOG_PATH") or os.getenv(
|
77
|
+
"SAFE_JSON_LOG_PATH"
|
78
|
+
)
|
79
|
+
self.jsonl_path: str = (
|
80
|
+
json_from_cfg
|
81
|
+
or json_from_env
|
82
|
+
or "safentic/logs/json_logs/safentic_audit.jsonl"
|
83
|
+
)
|
84
|
+
|
85
|
+
# Ensure directories exist (handle bare filenames)
|
86
|
+
txt_dir = os.path.dirname(self.txt_log_path) or "."
|
87
|
+
json_dir = os.path.dirname(self.jsonl_path) or "."
|
88
|
+
os.makedirs(txt_dir, exist_ok=True)
|
89
|
+
os.makedirs(json_dir, exist_ok=True)
|
90
|
+
|
91
|
+
# --------------------
|
92
|
+
# Logger setup
|
93
|
+
# --------------------
|
94
|
+
self.logger: logging.Logger = logging.getLogger("safentic.audit")
|
95
|
+
|
96
|
+
# Level with env override
|
97
|
+
level_str = (
|
98
|
+
cfg.get("level") or os.getenv("SAFENTIC_LOG_LEVEL") or "INFO"
|
99
|
+
).upper()
|
100
|
+
level_map = {
|
101
|
+
"DEBUG": logging.DEBUG,
|
102
|
+
"INFO": logging.INFO,
|
103
|
+
"WARNING": logging.WARNING,
|
104
|
+
"ERROR": logging.ERROR,
|
105
|
+
"CRITICAL": logging.CRITICAL,
|
106
|
+
}
|
107
|
+
level = level_map.get(level_str, logging.INFO)
|
108
|
+
self.logger.setLevel(level)
|
109
|
+
|
110
|
+
# Avoid duplicate handlers across multiple instantiations:
|
111
|
+
formatter = logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
|
112
|
+
|
113
|
+
# StreamHandler (only one; don't confuse with FileHandler which subclasses StreamHandler)
|
114
|
+
if not any(
|
115
|
+
isinstance(h, logging.StreamHandler)
|
116
|
+
and not isinstance(h, logging.FileHandler)
|
117
|
+
for h in self.logger.handlers
|
118
|
+
):
|
119
|
+
stream_handler = logging.StreamHandler()
|
120
|
+
stream_handler.setFormatter(formatter)
|
121
|
+
self.logger.addHandler(stream_handler)
|
122
|
+
|
123
|
+
# FileHandler for the text log (ensure we don't add duplicates for same file)
|
124
|
+
desired_file = os.path.abspath(self.txt_log_path)
|
125
|
+
has_file_handler = any(
|
126
|
+
isinstance(h, logging.FileHandler)
|
127
|
+
and getattr(h, "baseFilename", None) == desired_file
|
128
|
+
for h in self.logger.handlers
|
129
|
+
)
|
130
|
+
if not has_file_handler:
|
131
|
+
file_handler = logging.FileHandler(self.txt_log_path)
|
132
|
+
file_handler.setFormatter(formatter)
|
133
|
+
self.logger.addHandler(file_handler)
|
134
|
+
|
135
|
+
def log(
|
136
|
+
self,
|
137
|
+
agent_id: str,
|
138
|
+
tool: str,
|
139
|
+
allowed: bool,
|
140
|
+
reason: Optional[str] = None,
|
141
|
+
extra: Optional[Dict[str, Any]] = None,
|
142
|
+
) -> None:
|
143
|
+
"""Write audit logs to console, file, and JSONL. Supports structured extra metadata."""
|
144
|
+
if not self.enabled:
|
145
|
+
return
|
146
|
+
|
147
|
+
entry: Dict[str, Any] = {
|
148
|
+
"timestamp": datetime.now().isoformat(),
|
149
|
+
"agent_id": agent_id,
|
150
|
+
"tool": tool,
|
151
|
+
"allowed": allowed,
|
152
|
+
"reason": reason or "No violation",
|
153
|
+
}
|
154
|
+
|
155
|
+
if extra:
|
156
|
+
entry["extra"] = extra # structured metadata
|
157
|
+
|
158
|
+
log_level = logging.INFO if allowed else logging.WARNING
|
159
|
+
self.logger.log(log_level, f"[AUDIT] {entry}")
|
160
|
+
|
161
|
+
try:
|
162
|
+
with open(self.jsonl_path, "a", encoding="utf-8") as f:
|
163
|
+
f.write(json.dumps(entry) + "\n")
|
164
|
+
except Exception as e: # pragma: no cover - logging fallback path
|
165
|
+
# Only log this internal failure to the text logger; do not raise.
|
166
|
+
self.logger.error(f"Failed to write structured audit log: {e}")
|
167
|
+
|
168
|
+
def set_level(self, level: str) -> None:
|
169
|
+
level_map = {
|
170
|
+
"DEBUG": logging.DEBUG,
|
171
|
+
"INFO": logging.INFO,
|
172
|
+
"WARNING": logging.WARNING,
|
173
|
+
"ERROR": logging.ERROR,
|
174
|
+
"CRITICAL": logging.CRITICAL,
|
175
|
+
}
|
176
|
+
|
177
|
+
level_upper = level.upper()
|
178
|
+
if level_upper in level_map:
|
179
|
+
self.logger.setLevel(level_map[level_upper])
|
180
|
+
else:
|
181
|
+
raise ValueError(f"Unsupported log level: {level}")
|