safentic 1.0.5__py3-none-any.whl → 1.0.7__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.
@@ -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,93 @@
1
- from .engine import PolicyEnforcer
2
- from .logger.audit import AuditLogger
3
- from .helper.auth import validate_api_key
4
-
5
-
6
- class SafenticError(Exception):
7
- """Raised when Safentic blocks an action."""
8
- pass
9
-
10
-
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:
22
- """
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"})
29
- """
30
-
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
44
- self.agent_id = agent_id
45
- self.raise_on_block = raise_on_block
46
- self.logger = AuditLogger()
47
- self.enforcer = enforcer or PolicyEnforcer()
48
- self.enforcer.reset(agent_id)
49
-
50
- def call_tool(self, tool_name: str, tool_args: dict) -> dict:
51
- """
52
- Intercepts a tool call and enforces policies before execution.
53
- If blocked, raises `SafenticError` or returns an error response (configurable).
54
- """
55
- result = self.enforcer.enforce(self.agent_id, tool_name, tool_args)
56
-
57
- self.logger.log(
58
- agent_id=self.agent_id,
59
- tool=tool_name,
60
- allowed=result["allowed"],
61
- reason=result["reason"] if not result["allowed"] else None
62
- )
63
-
64
- if not result["allowed"]:
65
- if self.raise_on_block:
66
- raise SafenticError(result["reason"])
67
- return {"error": result["reason"]}
68
-
69
- return self.agent.call_tool(tool_name, **tool_args)
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
+ policy_path = os.getenv(
56
+ "SAFENTIC_POLICY_PATH",
57
+ os.path.abspath(os.path.join(os.getcwd(), "config", "policy.yaml")),
58
+ )
59
+
60
+ # Build engine and inject API key to verifier
61
+ engine = PolicyEngine(policy_path=policy_path)
62
+ engine.llm.set_api_key(os.getenv("OPENAI_API_KEY", ""))
63
+
64
+ # Strict enforcer injection
65
+ self.enforcer = PolicyEnforcer(policy_engine=engine)
66
+ self.enforcer.reset(agent_id)
67
+
68
+ def call_tool(self, tool_name: str, tool_args: dict[str, Any]) -> dict[str, Any]:
69
+ """
70
+ Intercepts a tool call and enforces policies before execution.
71
+ If blocked, raises `SafenticError` or returns an error response (configurable).
72
+ """
73
+ result: dict[str, Any] = self.enforcer.enforce(
74
+ self.agent_id, tool_name, tool_args
75
+ )
76
+
77
+ self.logger.log(
78
+ agent_id=self.agent_id,
79
+ tool=tool_name,
80
+ allowed=result["allowed"],
81
+ reason=result["reason"] if not result["allowed"] else None,
82
+ )
83
+
84
+ if not result["allowed"]:
85
+ if self.raise_on_block:
86
+ raise SafenticError(result["reason"])
87
+ return {
88
+ "error": result["reason"],
89
+ "tool": tool_name,
90
+ "violation": result.get("violation"),
91
+ }
92
+
93
+ return self.agent.call_tool(tool_name, **tool_args)
safentic/logger/audit.py CHANGED
@@ -1,83 +1,181 @@
1
- import logging
2
- from datetime import datetime
3
- import os
4
- import json
5
-
6
- class AuditLogger:
7
- def __init__(self, config: dict = None):
8
- config = config or {}
9
-
10
- # Allow disabling via config or env
11
- self.enabled = config.get("enabled", True)
12
- if os.getenv("SAFE_AUDIT_LOG") == "0":
13
- self.enabled = False
14
-
15
- # File paths from config or default
16
- self.txt_log_path = config.get("destination", "safentic/logs/txt_logs/safentic_audit.log")
17
- self.jsonl_path = config.get("jsonl", "safentic/logs/json_logs/safentic_audit.jsonl")
18
-
19
- # Ensure directories exist
20
- os.makedirs(os.path.dirname(self.txt_log_path), exist_ok=True)
21
- os.makedirs(os.path.dirname(self.jsonl_path), exist_ok=True)
22
-
23
- # Set up logger
24
- self.logger = logging.getLogger("safentic.audit")
25
-
26
- level_str = config.get("level", "INFO").upper()
27
- level_map = {
28
- "DEBUG": logging.DEBUG,
29
- "INFO": logging.INFO,
30
- "WARNING": logging.WARNING,
31
- "ERROR": logging.ERROR,
32
- "CRITICAL": logging.CRITICAL
33
- }
34
- level = level_map.get(level_str, logging.INFO)
35
- self.logger.setLevel(level)
36
-
37
- # Prevent duplicate handlers (e.g., in notebooks)
38
- if not self.logger.handlers:
39
- formatter = logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
40
-
41
- stream_handler = logging.StreamHandler()
42
- stream_handler.setFormatter(formatter)
43
- self.logger.addHandler(stream_handler)
44
-
45
- file_handler = logging.FileHandler(self.txt_log_path)
46
- file_handler.setFormatter(formatter)
47
- self.logger.addHandler(file_handler)
48
-
49
- def log(self, agent_id: str, tool: str, allowed: bool, reason: str = None):
50
- if not self.enabled:
51
- return
52
-
53
- entry = {
54
- "timestamp": datetime.now().isoformat(),
55
- "agent_id": agent_id,
56
- "tool": tool,
57
- "allowed": allowed,
58
- "reason": reason or "No violation"
59
- }
60
-
61
- log_level = logging.INFO if allowed else logging.WARNING
62
- self.logger.log(log_level, f"[AUDIT] {entry}")
63
-
64
- try:
65
- with open(self.jsonl_path, "a", encoding="utf-8") as f:
66
- f.write(json.dumps(entry) + "\n")
67
- except Exception as e:
68
- self.logger.error(f"Failed to write structured audit log: {e}")
69
-
70
- def set_level(self, level: str):
71
- level_map = {
72
- "DEBUG": logging.DEBUG,
73
- "INFO": logging.INFO,
74
- "WARNING": logging.WARNING,
75
- "ERROR": logging.ERROR,
76
- "CRITICAL": logging.CRITICAL
77
- }
78
-
79
- level = level.upper()
80
- if level in level_map:
81
- self.logger.setLevel(level_map[level])
82
- else:
83
- raise ValueError(f"Unsupported log level: {level}")
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}")