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
safentic/__init__.py
CHANGED
@@ -1,8 +1,5 @@
|
|
1
|
-
from .layer import SafetyLayer
|
1
|
+
from .layer import SafetyLayer
|
2
|
+
from ._internal.errors import SafenticError
|
2
3
|
|
3
|
-
__all__ = [
|
4
|
-
|
5
|
-
"SafenticError",
|
6
|
-
]
|
7
|
-
|
8
|
-
__version__ = "1.0.5"
|
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
|
+
}
|
safentic/cli/__init__.py
ADDED
@@ -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
|