safentic 1.0.4__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 CHANGED
@@ -1,8 +1,5 @@
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
2
+ from ._internal.errors import SafenticError
3
+
4
+ __all__ = ["SafetyLayer", "SafenticError"]
5
+ __version__ = "1.0.6"
@@ -0,0 +1,26 @@
1
+ class SafenticError(Exception):
2
+ """Base class for all Safentic errors."""
3
+
4
+
5
+ class PolicyValidationError(SafenticError):
6
+ """Raised when a policy file or rule is invalid."""
7
+
8
+
9
+ class ReferenceFileError(SafenticError):
10
+ """Raised when a reference file is missing, unreadable, or empty."""
11
+
12
+
13
+ class EnforcementError(SafenticError):
14
+ """Raised when enforcement fails unexpectedly."""
15
+
16
+
17
+ class VerifierError(SafenticError):
18
+ """Raised when the LLM verifier fails unexpectedly."""
19
+
20
+
21
+ class InvalidAPIKeyError(SafenticError):
22
+ """Raised when an API key is missing or invalid."""
23
+
24
+
25
+ class InvalidAgentInterfaceError(SafenticError):
26
+ """Raised when the wrapped agent doesn't expose the expected interface."""
@@ -0,0 +1,46 @@
1
+ from typing import Any, Dict, Mapping, cast
2
+ from safentic.policy_enforcer import PolicyEnforcer
3
+
4
+
5
+ def handle_mcp_action(
6
+ action_request: Mapping[str, Any], enforcer: PolicyEnforcer
7
+ ) -> Dict[str, Any]:
8
+ """
9
+ Accepts an MCP ActionRequest and returns a Safentic policy enforcement result.
10
+ Requires a PolicyEnforcer instance to be passed in.
11
+
12
+ Expected shape (minimal):
13
+ {
14
+ "tool": {
15
+ "name": str,
16
+ "input": dict[str, Any]
17
+ },
18
+ "agent": {
19
+ "id": str
20
+ }
21
+ }
22
+ """
23
+
24
+ tool: Dict[str, Any] = cast(Dict[str, Any], action_request.get("tool", {}))
25
+ agent: Dict[str, Any] = cast(Dict[str, Any], action_request.get("agent", {}))
26
+
27
+ tool_name: str = cast(str, tool.get("name", "unknown_tool"))
28
+ tool_args: Dict[str, Any] = cast(
29
+ Dict[str, Any], tool.get("input", {})
30
+ ) # Expected to include "body" or "note"
31
+ agent_id: str = cast(str, agent.get("id", "unknown_agent"))
32
+
33
+ result: Dict[str, Any] = enforcer.enforce(
34
+ agent_id=agent_id,
35
+ tool_name=tool_name,
36
+ tool_args=tool_args,
37
+ )
38
+
39
+ return {
40
+ "tool": tool_name,
41
+ "agent_id": agent_id,
42
+ "allowed": result["allowed"],
43
+ "reason": result["reason"],
44
+ "agent_state": result.get("agent_state", {}),
45
+ "violation": result.get("violation"), # Optional violation metadata
46
+ }
@@ -0,0 +1,3 @@
1
+ from typing import List
2
+
3
+ __all__: List[str] = []
@@ -0,0 +1,47 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from safentic.policy_engine import PolicyEngine
4
+ from safentic.policy_enforcer import PolicyEnforcer
5
+ from safentic.cli.utils import resolve_policy_path, load_json_arg, ok, error
6
+
7
+
8
+ def run(
9
+ policy: Optional[str],
10
+ tool_name: str,
11
+ agent_id: str,
12
+ input_json: Optional[str],
13
+ input_file: Optional[str],
14
+ dry_run: bool,
15
+ allow_fail: bool,
16
+ no_llm: bool = False,
17
+ ) -> Dict[str, Any]:
18
+ """
19
+ Run a one-off policy enforcement for a tool + payload.
20
+ Returns a JSON payload. Raises SystemExit(2) if blocked and allow_fail=False.
21
+ """
22
+ policy_path = resolve_policy_path(policy)
23
+
24
+ try:
25
+ engine = PolicyEngine(policy_path=policy_path, dry_run=dry_run, no_llm=no_llm)
26
+ enforcer = PolicyEnforcer(policy_engine=engine)
27
+
28
+ payload: Dict[str, Any] = load_json_arg(input_json, input_file)
29
+ decision = enforcer.enforce(
30
+ agent_id=agent_id, tool_name=tool_name, tool_args=payload
31
+ )
32
+
33
+ result = ok(
34
+ f"Check completed for tool: {tool_name}",
35
+ {"policy_path": policy_path, "decision": decision},
36
+ )
37
+
38
+ if not decision.get("allowed", False) and not allow_fail:
39
+ # Signal to CI callers that this is a block
40
+ raise SystemExit(2)
41
+
42
+ return result
43
+
44
+ except SystemExit:
45
+ raise
46
+ except Exception as e:
47
+ return error("check-tool failed", {"details": str(e)})
@@ -0,0 +1,66 @@
1
+ import io
2
+ import os
3
+ import time
4
+ from typing import Dict, Any, Optional
5
+
6
+ from safentic.cli.utils import resolve_log_path, ok, error, print_json
7
+
8
+
9
+ def _stream_tail(path: str) -> None:
10
+ """Follow a file until interrupted (like tail -f)."""
11
+ with open(path, "r", encoding="utf-8") as f:
12
+ f.seek(0, io.SEEK_END)
13
+ while True:
14
+ line = f.readline()
15
+ if not line:
16
+ time.sleep(0.25)
17
+ continue
18
+ print(line.rstrip())
19
+
20
+
21
+ def run_tail(
22
+ path: Optional[str] = None, follow: bool = False, prefer_json: bool = True
23
+ ) -> Optional[Dict[str, Any]]:
24
+ """
25
+ Tail logs (defaults to JSONL). If follow=True, stream and do not return a JSON envelope.
26
+ If follow=False, return last 200 lines as a JSON payload.
27
+ """
28
+ resolved_path, source = resolve_log_path(user_path=path, prefer_json=prefer_json)
29
+
30
+ if not os.path.exists(resolved_path):
31
+ return error(
32
+ "Log file not found",
33
+ {
34
+ "path": resolved_path,
35
+ "resolved_from": source,
36
+ "hint": "Generate logs via agent or check-tool first.",
37
+ },
38
+ )
39
+
40
+ if follow:
41
+ # Emit a one-time prelude so users know what is being tailed
42
+ print_json(
43
+ ok("Following log file", {"path": resolved_path, "resolved_from": source})
44
+ )
45
+ try:
46
+ _stream_tail(resolved_path)
47
+ except KeyboardInterrupt:
48
+ return None
49
+ return None
50
+
51
+ try:
52
+ with open(resolved_path, "r", encoding="utf-8") as f:
53
+ lines = f.readlines()[-200:]
54
+ return ok(
55
+ "Fetched recent logs",
56
+ {
57
+ "path": resolved_path,
58
+ "resolved_from": source,
59
+ "lines": [ln.rstrip() for ln in lines],
60
+ },
61
+ )
62
+ except Exception as e:
63
+ return error(
64
+ "Failed to read log file",
65
+ {"path": resolved_path, "resolved_from": source, "details": str(e)},
66
+ )
@@ -0,0 +1,59 @@
1
+ import os
2
+ from typing import Any, Dict, Optional
3
+
4
+ from safentic.policy_engine import PolicyEngine
5
+ from safentic.cli.utils import resolve_policy_path, ok, error
6
+ import yaml
7
+
8
+
9
+ def run(
10
+ policy: Optional[str] = None,
11
+ dry_run: bool = False,
12
+ strict: bool = False,
13
+ no_llm: bool = False,
14
+ ) -> Dict[str, Any]:
15
+ """Validate a policy file using the SDK loader/validator."""
16
+ policy_path = resolve_policy_path(policy)
17
+
18
+ if not os.path.exists(policy_path):
19
+ return error(
20
+ f"Policy validation failed for {policy_path}",
21
+ {"details": f"Policy file not found: {policy_path}"},
22
+ )
23
+
24
+ try:
25
+ engine = PolicyEngine(policy_path=policy_path, dry_run=dry_run, no_llm=no_llm)
26
+ tools_cfg: Dict[str, Any] = engine.policy_cfg.get("tools", {}) or {}
27
+ tool_count = len(tools_cfg)
28
+ rule_count = sum(
29
+ len((cfg or {}).get("rules", [])) for cfg in tools_cfg.values()
30
+ )
31
+
32
+ missing_refs: list[str] = []
33
+ if strict:
34
+ with open(policy_path, "r") as f:
35
+ policy_yaml = yaml.safe_load(f)
36
+ for tool in policy_yaml.get("tools", {}).values():
37
+ for rule in tool.get("rules", []):
38
+ ref_file = rule.get("reference_file")
39
+ if ref_file and not os.path.exists(ref_file):
40
+ missing_refs.append(ref_file)
41
+ if missing_refs:
42
+ return error(
43
+ "Policy validation failed: missing reference files.",
44
+ {"missing_files": missing_refs},
45
+ )
46
+
47
+ return ok(
48
+ "Policy validated successfully.",
49
+ {
50
+ "policy_path": os.path.abspath(policy_path),
51
+ "tools": tool_count,
52
+ "rules": rule_count,
53
+ "dry_run": engine.dry_run,
54
+ "no_llm": no_llm,
55
+ "strict": strict,
56
+ },
57
+ )
58
+ except Exception as e:
59
+ return error(f"Policy validation failed for {policy_path}", {"details": str(e)})
safentic/cli/main.py ADDED
@@ -0,0 +1,153 @@
1
+ import argparse
2
+ import sys
3
+ from typing import Any, Optional, Dict
4
+
5
+ from safentic.cli.commands.validate_policy import run as run_validate
6
+ from safentic.cli.commands.check_tool import run as run_check
7
+ from safentic.cli.commands.logs import run_tail as run_logs_tail
8
+ from safentic.cli.utils import print_output, set_output_mode
9
+
10
+
11
+ def build_parser() -> argparse.ArgumentParser:
12
+ parser = argparse.ArgumentParser(
13
+ prog="safentic",
14
+ description="Safentic CLI — validate policies, simulate tool checks, and tail audit logs.",
15
+ )
16
+ parser.add_argument("--version", action="version", version="safentic-cli 0.1.0")
17
+
18
+ # Common flags available on each subcommand
19
+ common = argparse.ArgumentParser(add_help=False)
20
+ common.add_argument(
21
+ "--policy", help="Path to policy.yaml (overrides SAFENTIC_POLICY_PATH)"
22
+ )
23
+ common.add_argument(
24
+ "--dry-run", action="store_true", help="Simulate enforcement (never block)"
25
+ )
26
+ common.add_argument(
27
+ "--json", action="store_true", help="Output machine-readable JSON"
28
+ )
29
+
30
+ sub = parser.add_subparsers(dest="command", required=True)
31
+
32
+ # validate-policy
33
+ p_val = sub.add_parser(
34
+ "validate-policy",
35
+ help="Validate a policy file and references",
36
+ parents=[common],
37
+ add_help=True,
38
+ )
39
+ p_val.add_argument(
40
+ "--strict",
41
+ action="store_true",
42
+ help="Fail if reference_file paths do not exist",
43
+ )
44
+ p_val.add_argument(
45
+ "--no-llm", action="store_true", help="Skip LLM-based checks for speed"
46
+ )
47
+ p_val.set_defaults(cmd="validate-policy")
48
+
49
+ # check-tool
50
+ p_chk = sub.add_parser(
51
+ "check-tool",
52
+ help="Run a one-off enforcement for a tool + JSON input",
53
+ parents=[common],
54
+ add_help=True,
55
+ )
56
+ p_chk.add_argument(
57
+ "--tool", required=True, help="Tool name to check (e.g., issue_refund)"
58
+ )
59
+ p_chk.add_argument(
60
+ "--agent-id", default="cli-agent", help="Agent ID (default: cli-agent)"
61
+ )
62
+ g = p_chk.add_mutually_exclusive_group()
63
+ g.add_argument("--input-json", help="Raw JSON string for tool input")
64
+ g.add_argument("--input-file", help="Path to JSON file for tool input")
65
+ p_chk.add_argument(
66
+ "--allow-fail",
67
+ action="store_true",
68
+ help="Exit code 0 even if blocked (local dev)",
69
+ )
70
+ p_chk.add_argument(
71
+ "--no-llm", action="store_true", help="Skip LLM-based checks for speed"
72
+ )
73
+ p_chk.set_defaults(cmd="check-tool")
74
+
75
+ # logs (tail)
76
+ p_logs = sub.add_parser("logs", help="Work with audit logs")
77
+ sub_logs = p_logs.add_subparsers(dest="logs_cmd", required=True)
78
+
79
+ p_tail = sub_logs.add_parser(
80
+ "tail",
81
+ help="Tail the audit log (JSONL by default)",
82
+ parents=[common],
83
+ add_help=True,
84
+ )
85
+ p_tail.add_argument("--path", help="Path to the log file (overrides env/config)")
86
+ p_tail.add_argument(
87
+ "-f",
88
+ "--follow",
89
+ action="store_true",
90
+ help="Follow appended lines (like tail -f)",
91
+ )
92
+ p_tail.set_defaults(cmd="logs:tail")
93
+
94
+ return parser
95
+
96
+
97
+ def _maybe_exit_on_error(payload: Optional[Dict[str, Any]]) -> None:
98
+ """Exit with code 1 if payload indicates an error."""
99
+ if payload is not None and not payload.get("ok", False):
100
+ sys.exit(1)
101
+
102
+
103
+ def main(argv: Optional[list[Any]] = None) -> None:
104
+ parser = build_parser()
105
+ args = parser.parse_args(argv)
106
+ payload: Dict[str, Any] = {}
107
+ # Set global output mode
108
+ set_output_mode(getattr(args, "json", False))
109
+
110
+ if args.command == "validate-policy":
111
+ payload = run_validate(
112
+ policy=args.policy,
113
+ dry_run=getattr(args, "dry_run", False),
114
+ strict=getattr(args, "strict", False),
115
+ no_llm=getattr(args, "no_llm", False),
116
+ )
117
+ print_output(payload)
118
+ _maybe_exit_on_error(payload)
119
+ return
120
+
121
+ if args.command == "check-tool":
122
+ try:
123
+ payload = run_check(
124
+ policy=args.policy,
125
+ tool_name=args.tool,
126
+ agent_id=args.agent_id,
127
+ input_json=getattr(args, "input_json", None),
128
+ input_file=getattr(args, "input_file", None),
129
+ dry_run=getattr(args, "dry_run", False),
130
+ allow_fail=getattr(args, "allow_fail", False),
131
+ no_llm=getattr(args, "no_llm", False),
132
+ )
133
+ print_output(payload)
134
+ _maybe_exit_on_error(payload)
135
+ except SystemExit:
136
+ # allow exit code 2 (blocked) to propagate for CI
137
+ raise
138
+ return
139
+
140
+ if args.command == "logs" and args.logs_cmd == "tail":
141
+ log_payload: Optional[Dict[str, Any]] = run_logs_tail(
142
+ path=getattr(args, "path", None),
143
+ follow=getattr(args, "follow", False),
144
+ prefer_json=True,
145
+ )
146
+ if log_payload is not None:
147
+ print_output(log_payload)
148
+ _maybe_exit_on_error(log_payload)
149
+ return
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()
safentic/cli/utils.py ADDED
@@ -0,0 +1,169 @@
1
+ import os
2
+ import json
3
+ from typing import Any, Dict, Optional, Tuple
4
+
5
+ # -------------------------------------------------
6
+ # Output formatting
7
+ # -------------------------------------------------
8
+
9
+ _OUTPUT_JSON = False # default: pretty (plain) output
10
+
11
+
12
+ def set_output_mode(json_mode: bool) -> None:
13
+ """Set global output mode for the CLI."""
14
+ global _OUTPUT_JSON
15
+ _OUTPUT_JSON = bool(json_mode)
16
+
17
+
18
+ def ok(message: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
19
+ return {"ok": True, "message": message, "data": data or {}}
20
+
21
+
22
+ def error(message: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
23
+ return {"ok": False, "message": message, "error": data or {}}
24
+
25
+
26
+ def _pretty(payload: Dict[str, Any]) -> str:
27
+ if payload.get("ok", True):
28
+ msg = f"{payload.get('message','OK')}"
29
+ data = payload.get("data") or {}
30
+ if data:
31
+ parts = []
32
+ for k, v in data.items():
33
+ if isinstance(v, (list, tuple)):
34
+ if k == "lines":
35
+ parts.append(f"{k}:\n" + "\n".join(str(x) for x in v))
36
+ else:
37
+ parts.append(f"{k}: {len(v)} items")
38
+ elif isinstance(v, dict):
39
+ # Show small dicts inline, otherwise key count
40
+ items = ", ".join(f"{ik}={iv}" for ik, iv in list(v.items())[:6])
41
+ suffix = "" if len(v) <= 6 else ", …"
42
+ parts.append(f"{k}: {items}{suffix}")
43
+ else:
44
+ parts.append(f"{k}: {v}")
45
+ if parts:
46
+ return msg + "\n" + "\n".join(parts)
47
+ return msg
48
+ else:
49
+ msg = f"{payload.get('message','Error')}"
50
+ err = payload.get("error") or {}
51
+ if err:
52
+ try:
53
+ return msg + "\n" + json.dumps(err, indent=2)
54
+ except Exception:
55
+ return msg + "\n" + str(err)
56
+ return msg
57
+
58
+
59
+ def print_output(payload: Dict[str, Any]) -> None:
60
+ if _OUTPUT_JSON:
61
+ print(json.dumps(payload, indent=2))
62
+ else:
63
+ print(_pretty(payload))
64
+
65
+
66
+ def print_json(payload: Dict[str, Any]) -> None:
67
+ """Sometimes needed for streaming prelude messages."""
68
+ print(json.dumps(payload, indent=2))
69
+
70
+
71
+ # -------------------------------------------------
72
+ # Helpers: policy & logs path resolution, input loading
73
+ # -------------------------------------------------
74
+
75
+
76
+ def resolve_policy_path(arg_path: Optional[str]) -> str:
77
+ """
78
+ Precedence:
79
+ 1) --policy (arg)
80
+ 2) SAFENTIC_POLICY_PATH (env)
81
+ 3) repo default: config/policy.yaml
82
+ """
83
+ if arg_path:
84
+ return os.path.abspath(arg_path)
85
+ env_path = os.getenv("SAFENTIC_POLICY_PATH")
86
+ if env_path:
87
+ return os.path.abspath(env_path)
88
+ return os.path.abspath(
89
+ os.path.join(os.path.dirname(__file__), "..", "..", "config", "policy.yaml")
90
+ )
91
+
92
+
93
+ def load_json_arg(
94
+ input_json: Optional[str], input_file: Optional[str]
95
+ ) -> Dict[str, Any]:
96
+ """Load JSON from either a raw string or a file path (mutually exclusive).
97
+ Ensures the top-level value is a JSON object (dict).
98
+ """
99
+ if input_json and input_file:
100
+ raise ValueError("Provide either --input-json OR --input-file, not both.")
101
+
102
+ parsed: Any = None
103
+ if input_json:
104
+ try:
105
+ parsed = json.loads(input_json)
106
+ except json.JSONDecodeError as e:
107
+ raise ValueError(f"Invalid JSON in --input-json: {e}") from e
108
+ if isinstance(parsed, dict):
109
+ return parsed
110
+ raise ValueError(
111
+ 'Expected a JSON object for --input-json (e.g., {"key": "value"}).'
112
+ )
113
+
114
+ if input_file:
115
+ if not os.path.isfile(input_file):
116
+ raise FileNotFoundError(f"Input file not found: {input_file}")
117
+ with open(input_file, "r", encoding="utf-8") as f:
118
+ try:
119
+ parsed = json.load(f)
120
+ except json.JSONDecodeError as e:
121
+ raise ValueError(f"Invalid JSON in --input-file: {e}") from e
122
+ if isinstance(parsed, dict):
123
+ return parsed
124
+ raise ValueError(
125
+ "Expected a JSON object in --input-file (top-level must be an object)."
126
+ )
127
+
128
+ return {}
129
+
130
+
131
+ def resolve_log_path(
132
+ user_path: Optional[str] = None, prefer_json: bool = True
133
+ ) -> Tuple[str, str]:
134
+ """
135
+ Resolve log path dynamically with precedence:
136
+ 1) explicit --path (user_path)
137
+ 2) environment variables (SAFENTIC_JSON_LOG_PATH / SAFENTIC_LOG_PATH / SAFE_JSON_LOG_PATH / SAFE_LOG_PATH)
138
+ 3) AuditLogger config (jsonl_path / txt_log_path)
139
+ 4) repo default fallback under safentic/logs/...
140
+ Returns (resolved_path, source_hint).
141
+ """
142
+ if user_path:
143
+ return os.path.abspath(user_path), "cli_arg"
144
+
145
+ env_keys_json = ["SAFENTIC_JSON_LOG_PATH", "SAFE_JSON_LOG_PATH"]
146
+ env_keys_txt = ["SAFENTIC_LOG_PATH", "SAFE_LOG_PATH"]
147
+ for key in env_keys_json if prefer_json else env_keys_txt:
148
+ val = os.getenv(key)
149
+ if val:
150
+ return os.path.abspath(val), f"env:{key}"
151
+
152
+ # Try AuditLogger configuration (lazy import)
153
+ try:
154
+ from safentic.logger.audit import AuditLogger
155
+
156
+ logger = AuditLogger()
157
+ resolved = logger.jsonl_path if prefer_json else logger.txt_log_path
158
+ if resolved:
159
+ return os.path.abspath(resolved), "audit_logger"
160
+ except Exception:
161
+ pass
162
+
163
+ # Fallback
164
+ fallback = (
165
+ os.path.join("safentic", "logs", "json_logs", "safentic_audit.jsonl")
166
+ if prefer_json
167
+ else os.path.join("safentic", "logs", "txt_logs", "safentic_audit.log")
168
+ )
169
+ return os.path.abspath(fallback), "fallback"
safentic/config.py CHANGED
@@ -1,2 +1,2 @@
1
- BASE_API_PATH = "https://safentic-api.onrender.com/"
2
- API_KEY_ENDPOINT = "auth/validate"
1
+ BASE_API_PATH = "https://safentic-api.onrender.com/"
2
+ API_KEY_ENDPOINT = "auth/validate"
safentic/decorators.py ADDED
@@ -0,0 +1,49 @@
1
+ # safentic/decorators.py
2
+
3
+ from functools import wraps
4
+ from typing import Any, Callable, TypeVar, ParamSpec
5
+
6
+ from safentic.adapters.mcp_adapter import handle_mcp_action
7
+ from safentic.policy_engine import PolicyEngine
8
+ from safentic.policy_enforcer import PolicyEnforcer
9
+
10
+ engine = PolicyEngine(policy_path="config/policy.yaml")
11
+ enforcer = PolicyEnforcer(policy_engine=engine)
12
+
13
+ P = ParamSpec("P")
14
+ R = TypeVar("R")
15
+
16
+
17
+ def enforce(
18
+ tool_name: str, agent_id: str = "default-agent"
19
+ ) -> Callable[[Callable[P, R]], Callable[P, R | str]]:
20
+ """
21
+ Decorator that routes tool calls through Safentic via MCP.
22
+
23
+ Note: The wrapped function may return its original type R, or a str when blocked.
24
+ """
25
+
26
+ def decorator(func: Callable[P, R]) -> Callable[P, R | str]:
27
+ @wraps(func)
28
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | str:
29
+ tool_input: dict[str, Any] = kwargs or {"note": args[0]} if args else {}
30
+
31
+ action_request: dict[str, Any] = {
32
+ "tool": {"name": tool_name, "input": tool_input},
33
+ "agent": {"id": agent_id},
34
+ }
35
+
36
+ # 🔹 Always pass the enforcer
37
+ result: dict[str, Any] = handle_mcp_action(action_request, enforcer)
38
+
39
+ if not result["allowed"]:
40
+ print(
41
+ f"\n[BLOCKED by Safentic] {tool_name.upper()} — {result['reason']}"
42
+ )
43
+ return f"[BLOCKED] {result['reason']}"
44
+
45
+ return func(*args, **kwargs)
46
+
47
+ return wrapper
48
+
49
+ return decorator