kaizen-security 0.1.0__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.
- kaizen_security/__init__.py +5 -0
- kaizen_security/client.py +141 -0
- kaizen_security/engine.py +147 -0
- kaizen_security/integrations/__init__.py +1 -0
- kaizen_security/integrations/langchain.py +62 -0
- kaizen_security/integrations/openai_agents.py +57 -0
- kaizen_security/integrations/otel.py +39 -0
- kaizen_security/mcp_shim.py +109 -0
- kaizen_security/models.py +73 -0
- kaizen_security/py.typed +0 -0
- kaizen_security/report.py +89 -0
- kaizen_security-0.1.0.dist-info/METADATA +94 -0
- kaizen_security-0.1.0.dist-info/RECORD +16 -0
- kaizen_security-0.1.0.dist-info/WHEEL +5 -0
- kaizen_security-0.1.0.dist-info/entry_points.txt +2 -0
- kaizen_security-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import threading
|
|
5
|
+
import warnings
|
|
6
|
+
from collections import deque
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from . import engine
|
|
10
|
+
from .models import Action, Finding, Policy, Verdict
|
|
11
|
+
from .report import Reporter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class KaizenBlocked(Exception):
|
|
15
|
+
def __init__(self, verdict: Verdict):
|
|
16
|
+
self.verdict = verdict
|
|
17
|
+
super().__init__(verdict.reason)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Kaizen:
|
|
21
|
+
"""The data plane client. Enforces policy locally for low latency, and
|
|
22
|
+
reports verdicts to the control plane for the dashboard.
|
|
23
|
+
|
|
24
|
+
kz = Kaizen(api_key="kz_live_...")
|
|
25
|
+
v = kz.inspect(tool="clawhub2", publisher="hightower6eu")
|
|
26
|
+
if v.blocked: ...
|
|
27
|
+
|
|
28
|
+
fail_open defaults to False: if our own evaluation errors, we block. This is
|
|
29
|
+
a security control, so the safe failure is to deny. Set fail_open=True only
|
|
30
|
+
if you accept that our bugs become a bypass, and watch your logs.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: str = "",
|
|
36
|
+
base_url: str = "https://api.getkaizen.io",
|
|
37
|
+
policies: Optional[list[Policy]] = None,
|
|
38
|
+
agent: str = "default",
|
|
39
|
+
fail_open: bool = False,
|
|
40
|
+
report: bool = True,
|
|
41
|
+
sync: bool = True,
|
|
42
|
+
on_verdict=None,
|
|
43
|
+
):
|
|
44
|
+
self.api_key = api_key
|
|
45
|
+
self.base_url = self._check_url(base_url.rstrip("/"))
|
|
46
|
+
self.policies = list(policies) if policies else []
|
|
47
|
+
self.agent = agent
|
|
48
|
+
self.fail_open = fail_open
|
|
49
|
+
self._session: deque[Action] = deque(maxlen=20)
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
self._report = report
|
|
52
|
+
# the reporter exists whenever there is an api_key (it also fetches policy);
|
|
53
|
+
# the report flag only controls whether we send verdicts back.
|
|
54
|
+
self._reporter = Reporter(self.base_url, api_key) if api_key else None
|
|
55
|
+
# optional callback fired after every verdict (used by the OTel exporter)
|
|
56
|
+
self._on_verdict = on_verdict
|
|
57
|
+
if sync and api_key and not self.policies:
|
|
58
|
+
self._sync_policies()
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _check_url(url: str) -> str:
|
|
62
|
+
if url.startswith("http://") and not url.startswith(("http://localhost", "http://127.0.0.1")):
|
|
63
|
+
warnings.warn(
|
|
64
|
+
"Kaizen base_url is http://, the API key and action data would travel in cleartext. Use https.",
|
|
65
|
+
stacklevel=3,
|
|
66
|
+
)
|
|
67
|
+
return url
|
|
68
|
+
|
|
69
|
+
def _sync_policies(self) -> None:
|
|
70
|
+
if not self._reporter:
|
|
71
|
+
return
|
|
72
|
+
fetched = self._reporter.get_policies(self.agent)
|
|
73
|
+
if fetched is not None:
|
|
74
|
+
self.policies = fetched
|
|
75
|
+
|
|
76
|
+
def inspect(self, action: Optional[Action] = None, **kw) -> Verdict:
|
|
77
|
+
a = action if action is not None else Action(**kw)
|
|
78
|
+
try:
|
|
79
|
+
v = engine.evaluate(a, self.policies)
|
|
80
|
+
except Exception:
|
|
81
|
+
v = Verdict("allow" if self.fail_open else "block", "engine error, failed " + ("open" if self.fail_open else "closed"))
|
|
82
|
+
|
|
83
|
+
# correlation may only escalate an allow to a block, never downgrade a block
|
|
84
|
+
if v.allowed:
|
|
85
|
+
try:
|
|
86
|
+
v = self._correlate(a, v)
|
|
87
|
+
except Exception:
|
|
88
|
+
if not self.fail_open:
|
|
89
|
+
v = Verdict("block", "correlation error, failed closed")
|
|
90
|
+
|
|
91
|
+
with self._lock:
|
|
92
|
+
self._session.append(a)
|
|
93
|
+
if self._reporter and self._report:
|
|
94
|
+
self._reporter.send(self.agent, a, v)
|
|
95
|
+
if self._on_verdict:
|
|
96
|
+
try:
|
|
97
|
+
self._on_verdict(v, a)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
return v
|
|
101
|
+
|
|
102
|
+
def enforce(self, action: Optional[Action] = None, **kw) -> Verdict:
|
|
103
|
+
v = self.inspect(action, **kw)
|
|
104
|
+
if v.blocked:
|
|
105
|
+
raise KaizenBlocked(v)
|
|
106
|
+
return v
|
|
107
|
+
|
|
108
|
+
def _correlate(self, a: Action, v: Verdict) -> Verdict:
|
|
109
|
+
# v1 session rule: a sensitive read earlier, then an outbound connect now.
|
|
110
|
+
for p in self.policies:
|
|
111
|
+
if not p.enabled or p.mode != "correlation":
|
|
112
|
+
continue
|
|
113
|
+
if (p.rules or {}).get("risky_sequence") == "read_then_connect" and a.kind == "connect":
|
|
114
|
+
with self._lock:
|
|
115
|
+
prior = list(self._session)
|
|
116
|
+
if any(prev.kind == "file" and (prev.metadata or {}).get("sensitive") for prev in prior):
|
|
117
|
+
return Verdict(
|
|
118
|
+
"block",
|
|
119
|
+
"blocked by correlation: sensitive read then outbound connect",
|
|
120
|
+
[Finding("risky sequence", "sensitive read then connect", "correlation")],
|
|
121
|
+
)
|
|
122
|
+
return v
|
|
123
|
+
|
|
124
|
+
def guard(self, fn=None, *, tool: Optional[str] = None, kind: str = "tool_call"):
|
|
125
|
+
"""Decorator that inspects a tool call before running it.
|
|
126
|
+
|
|
127
|
+
The inspected tool name defaults to the function name; pass tool= to
|
|
128
|
+
match the name your agent actually invokes. We do not serialize the
|
|
129
|
+
call arguments into the action, to avoid shipping secrets.
|
|
130
|
+
"""
|
|
131
|
+
def deco(func):
|
|
132
|
+
name = tool or getattr(func, "__name__", "tool")
|
|
133
|
+
|
|
134
|
+
@functools.wraps(func)
|
|
135
|
+
def wrapper(*args, **kwargs):
|
|
136
|
+
self.enforce(Action(kind=kind, tool=name))
|
|
137
|
+
return func(*args, **kwargs)
|
|
138
|
+
|
|
139
|
+
return wrapper
|
|
140
|
+
|
|
141
|
+
return deco(fn) if callable(fn) else deco
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import re
|
|
5
|
+
import unicodedata
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from .models import Action, Finding, Policy, Verdict
|
|
10
|
+
|
|
11
|
+
# zero-width and format characters used to disguise lookalike strings
|
|
12
|
+
_ZERO_WIDTH = dict.fromkeys(map(ord, ""), None)
|
|
13
|
+
|
|
14
|
+
_MAX_PATTERN = 200 # cap remote regex length to bound catastrophic backtracking
|
|
15
|
+
_MAX_TOOL = 512 # cap the string we match against
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _norm(s: Optional[str]) -> Optional[str]:
|
|
19
|
+
if not isinstance(s, str):
|
|
20
|
+
return s
|
|
21
|
+
return unicodedata.normalize("NFKC", s).translate(_ZERO_WIDTH).strip().lower()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _as_ip(host: str):
|
|
25
|
+
"""Parse a host as an IP, tolerating integer form. Returns None if not an IP."""
|
|
26
|
+
try:
|
|
27
|
+
return ipaddress.ip_address(host)
|
|
28
|
+
except ValueError:
|
|
29
|
+
pass
|
|
30
|
+
if host.isdigit():
|
|
31
|
+
try:
|
|
32
|
+
return ipaddress.ip_address(int(host))
|
|
33
|
+
except ValueError:
|
|
34
|
+
return None
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _host_of(target: str):
|
|
39
|
+
"""Pull the host out of a URL or host:port, return (ip_or_none, host_lower)."""
|
|
40
|
+
t = target.strip()
|
|
41
|
+
if "://" in t:
|
|
42
|
+
t = urlparse(t).hostname or t
|
|
43
|
+
else:
|
|
44
|
+
t = t.split("/", 1)[0]
|
|
45
|
+
if t.startswith("[") and "]" in t: # IPv6 with brackets
|
|
46
|
+
host = t[1 : t.index("]")]
|
|
47
|
+
elif t.count(":") == 1: # host:port
|
|
48
|
+
host = t.rsplit(":", 1)[0]
|
|
49
|
+
else:
|
|
50
|
+
host = t
|
|
51
|
+
host = host.rstrip(".").lower()
|
|
52
|
+
return _as_ip(host), host
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _networks(values) -> list:
|
|
56
|
+
nets = []
|
|
57
|
+
for v in values or []:
|
|
58
|
+
try:
|
|
59
|
+
nets.append(ipaddress.ip_network(str(v).strip(), strict=False))
|
|
60
|
+
except ValueError:
|
|
61
|
+
continue
|
|
62
|
+
return nets
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _domain_hit(host: str, domains) -> Optional[str]:
|
|
66
|
+
h = host.rstrip(".").lower()
|
|
67
|
+
for d in domains or []:
|
|
68
|
+
dn = (_norm(str(d)) or "").rstrip(".")
|
|
69
|
+
if dn and (h == dn or h.endswith("." + dn)): # match the domain and any subdomain
|
|
70
|
+
return dn
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def evaluate(action: Action, policies: list[Policy]) -> Verdict:
|
|
75
|
+
"""Stateless evaluation of allowlist and blocklist policies.
|
|
76
|
+
|
|
77
|
+
Session-aware correlation lives in the client, which holds the session.
|
|
78
|
+
Defensive throughout: a malformed remote policy must not throw, because the
|
|
79
|
+
client's outer handler would otherwise fail the whole evaluation open.
|
|
80
|
+
"""
|
|
81
|
+
findings: list[Finding] = []
|
|
82
|
+
for p in policies:
|
|
83
|
+
if not p.enabled:
|
|
84
|
+
continue
|
|
85
|
+
rules = p.rules or {}
|
|
86
|
+
if p.mode == "blocklist":
|
|
87
|
+
findings += _blocklist(action, rules)
|
|
88
|
+
elif p.mode == "allowlist":
|
|
89
|
+
f = _allowlist(action, rules)
|
|
90
|
+
if f:
|
|
91
|
+
findings.append(f)
|
|
92
|
+
if findings:
|
|
93
|
+
kinds = ", ".join(sorted({f.kind for f in findings}))
|
|
94
|
+
return Verdict("block", f"blocked by policy: {kinds}", findings)
|
|
95
|
+
return Verdict("allow", "no policy matched")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _blocklist(a: Action, rules: dict) -> list[Finding]:
|
|
99
|
+
out: list[Finding] = []
|
|
100
|
+
|
|
101
|
+
pub = _norm(a.publisher)
|
|
102
|
+
if pub and pub in {_norm(x) for x in (rules.get("publishers") or [])}:
|
|
103
|
+
out.append(Finding("blacklisted publisher", a.publisher, "blocklist"))
|
|
104
|
+
|
|
105
|
+
if a.target:
|
|
106
|
+
ip, host = _host_of(a.target)
|
|
107
|
+
if ip is not None:
|
|
108
|
+
if any(ip in net for net in _networks(rules.get("ips"))):
|
|
109
|
+
out.append(Finding("known-bad address", str(ip), "blocklist"))
|
|
110
|
+
else:
|
|
111
|
+
hit = _domain_hit(host, rules.get("domains"))
|
|
112
|
+
if hit:
|
|
113
|
+
out.append(Finding("malicious domain", host, "blocklist"))
|
|
114
|
+
|
|
115
|
+
if a.hash:
|
|
116
|
+
h = a.hash.strip().lower()
|
|
117
|
+
if h in {str(x).strip().lower() for x in (rules.get("hashes") or [])}:
|
|
118
|
+
out.append(Finding("known-bad hash", a.hash[:16] + "…", "blocklist"))
|
|
119
|
+
|
|
120
|
+
if a.tool:
|
|
121
|
+
tool = a.tool[:_MAX_TOOL]
|
|
122
|
+
for pat in (rules.get("skill_patterns") or []):
|
|
123
|
+
if not isinstance(pat, str) or len(pat) > _MAX_PATTERN:
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
if re.fullmatch(pat, tool):
|
|
127
|
+
out.append(Finding("malicious skill pattern", a.tool, "blocklist"))
|
|
128
|
+
break
|
|
129
|
+
except re.error:
|
|
130
|
+
continue
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _allowlist(a: Action, rules: dict) -> Optional[Finding]:
|
|
135
|
+
"""Default-deny. Every dimension the policy constrains must match (AND).
|
|
136
|
+
|
|
137
|
+
Policy validation guarantees at least one of publishers or tools is set, so
|
|
138
|
+
an empty allowlist can never silently block everything.
|
|
139
|
+
"""
|
|
140
|
+
allowed_pubs = {_norm(x) for x in (rules.get("publishers") or [])}
|
|
141
|
+
allowed_tools = {_norm(x) for x in (rules.get("tools") or [])}
|
|
142
|
+
|
|
143
|
+
if allowed_pubs and _norm(a.publisher) not in allowed_pubs:
|
|
144
|
+
return Finding("publisher not on the allowlist", a.publisher or "none", "allowlist")
|
|
145
|
+
if allowed_tools and _norm(a.tool) not in allowed_tools:
|
|
146
|
+
return Finding("tool not on the allowlist", a.tool or "none", "allowlist")
|
|
147
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Framework adapters that attach Kaizen to existing agent runtimes."""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Attach Kaizen to a LangChain / LangGraph agent.
|
|
2
|
+
|
|
3
|
+
Observe every tool call with the callback handler:
|
|
4
|
+
|
|
5
|
+
from kaizen_security import Kaizen
|
|
6
|
+
from kaizen_security.integrations.langchain import KaizenCallbackHandler
|
|
7
|
+
|
|
8
|
+
kz = Kaizen(api_key="kz_live_...", agent="support-bot")
|
|
9
|
+
agent.invoke({"input": "..."}, config={"callbacks": [KaizenCallbackHandler(kz)]})
|
|
10
|
+
|
|
11
|
+
The callback is observe-only: LangChain swallows exceptions raised inside
|
|
12
|
+
callbacks, so it cannot block a tool. To BLOCK, wrap the tool instead:
|
|
13
|
+
|
|
14
|
+
from kaizen_security.integrations.langchain import guard_tool
|
|
15
|
+
safe_tool = guard_tool(kz, my_tool)
|
|
16
|
+
|
|
17
|
+
langchain is an optional dependency; this module imports it lazily.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from ..models import Action
|
|
22
|
+
|
|
23
|
+
try: # modern LangChain
|
|
24
|
+
from langchain_core.callbacks import BaseCallbackHandler as _Base
|
|
25
|
+
except Exception: # pragma: no cover
|
|
26
|
+
try: # older LangChain
|
|
27
|
+
from langchain.callbacks.base import BaseCallbackHandler as _Base
|
|
28
|
+
except Exception:
|
|
29
|
+
_Base = object
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class KaizenCallbackHandler(_Base):
|
|
33
|
+
"""Reports every tool call to Kaizen so the Observer learns the agent's
|
|
34
|
+
behavior and flags deviations. Observe-only (see guard_tool to block)."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, kaizen):
|
|
37
|
+
self.kaizen = kaizen
|
|
38
|
+
|
|
39
|
+
def on_tool_start(self, serialized, input_str, **kwargs):
|
|
40
|
+
name = (serialized or {}).get("name") or "tool"
|
|
41
|
+
self.kaizen.inspect(Action(kind="tool_call", tool=name, metadata={"input": input_str}))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def guard_tool(kaizen, tool, enforce: bool = True):
|
|
45
|
+
"""Wrap a LangChain tool so Kaizen inspects each call. With enforce (default),
|
|
46
|
+
a blocked call returns a refusal string instead of executing the tool."""
|
|
47
|
+
from langchain_core.tools import StructuredTool
|
|
48
|
+
|
|
49
|
+
name = getattr(tool, "name", None) or "tool"
|
|
50
|
+
|
|
51
|
+
def _wrapped(**kwargs):
|
|
52
|
+
verdict = kaizen.inspect(Action(kind="tool_call", tool=name, metadata={"input": kwargs}))
|
|
53
|
+
if enforce and verdict.blocked:
|
|
54
|
+
return f"Blocked by Kaizen: {verdict.reason}"
|
|
55
|
+
return tool.invoke(kwargs)
|
|
56
|
+
|
|
57
|
+
return StructuredTool.from_function(
|
|
58
|
+
func=_wrapped,
|
|
59
|
+
name=name,
|
|
60
|
+
description=getattr(tool, "description", "") or "",
|
|
61
|
+
args_schema=getattr(tool, "args_schema", None),
|
|
62
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Attach Kaizen to an OpenAI Agents SDK agent in one line.
|
|
2
|
+
|
|
3
|
+
from agents import Agent, Runner
|
|
4
|
+
from kaizen_security import Kaizen
|
|
5
|
+
from kaizen_security.integrations.openai_agents import KaizenHooks
|
|
6
|
+
|
|
7
|
+
kz = Kaizen(api_key="kz_live_...", agent="support-bot")
|
|
8
|
+
await Runner.run(agent, "do the thing", hooks=KaizenHooks(kz))
|
|
9
|
+
|
|
10
|
+
Every tool the agent calls flows to Kaizen: it is inspected against policy and
|
|
11
|
+
reported, so the isolated Observer learns the agent's behavior and flags
|
|
12
|
+
deviations. Observe-only by default; with enforce=True a blocked tool is stopped
|
|
13
|
+
before it runs (via the SDK's tool-rejection path, falling back to raising).
|
|
14
|
+
|
|
15
|
+
Importing this module does not require openai-agents to be installed; KaizenHooks
|
|
16
|
+
only needs it at runtime when you actually run an agent.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from ..client import KaizenBlocked
|
|
21
|
+
from ..models import Action
|
|
22
|
+
|
|
23
|
+
try: # the base class is provided by openai-agents when present
|
|
24
|
+
from agents import RunHooks as _Base
|
|
25
|
+
except Exception: # pragma: no cover - openai-agents optional
|
|
26
|
+
_Base = object
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _tool_name(tool) -> str:
|
|
30
|
+
return getattr(tool, "name", None) or getattr(tool, "__name__", None) or "tool"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _tool_args(context):
|
|
34
|
+
for attr in ("tool_input", "tool_arguments"):
|
|
35
|
+
v = getattr(context, attr, None)
|
|
36
|
+
if v is not None:
|
|
37
|
+
return v
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class KaizenHooks(_Base):
|
|
42
|
+
"""RunHooks that route every tool call through Kaizen."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, kaizen, enforce: bool = False):
|
|
45
|
+
self.kaizen = kaizen
|
|
46
|
+
self.enforce = enforce
|
|
47
|
+
|
|
48
|
+
async def on_tool_start(self, context, agent, tool):
|
|
49
|
+
action = Action(kind="tool_call", tool=_tool_name(tool), metadata={"arguments": _tool_args(context)})
|
|
50
|
+
verdict = self.kaizen.inspect(action)
|
|
51
|
+
if self.enforce and verdict.blocked:
|
|
52
|
+
reject = getattr(context, "reject_tool", None)
|
|
53
|
+
if callable(reject):
|
|
54
|
+
reject(verdict.reason) # clean: tool never executes
|
|
55
|
+
else:
|
|
56
|
+
raise KaizenBlocked(verdict)
|
|
57
|
+
return None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Emit an OpenTelemetry span for each Kaizen verdict.
|
|
2
|
+
|
|
3
|
+
from kaizen_security import Kaizen
|
|
4
|
+
from kaizen_security.integrations.otel import record_verdict
|
|
5
|
+
|
|
6
|
+
kz = Kaizen(api_key="kz_live_...", on_verdict=record_verdict)
|
|
7
|
+
|
|
8
|
+
Every inspect() then appears in your traces as a `kaizen.inspect` span carrying
|
|
9
|
+
the decision, reason, tool, and target. Blocked verdicts mark the span as an
|
|
10
|
+
error, so they surface in Datadog, Grafana, Honeycomb, or any OTel backend with
|
|
11
|
+
no per-vendor code. opentelemetry is an optional dependency.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from opentelemetry import trace as _trace
|
|
17
|
+
except Exception: # pragma: no cover - opentelemetry optional
|
|
18
|
+
_trace = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def record_verdict(verdict, action=None):
|
|
22
|
+
if _trace is None:
|
|
23
|
+
return
|
|
24
|
+
tracer = _trace.get_tracer("kaizen-security")
|
|
25
|
+
with tracer.start_as_current_span("kaizen.inspect") as span:
|
|
26
|
+
span.set_attribute("kaizen.decision", verdict.decision)
|
|
27
|
+
if getattr(verdict, "reason", None):
|
|
28
|
+
span.set_attribute("kaizen.reason", verdict.reason)
|
|
29
|
+
if action is not None:
|
|
30
|
+
if action.kind:
|
|
31
|
+
span.set_attribute("kaizen.kind", action.kind)
|
|
32
|
+
if action.tool:
|
|
33
|
+
span.set_attribute("kaizen.tool", action.tool)
|
|
34
|
+
if getattr(action, "publisher", None):
|
|
35
|
+
span.set_attribute("kaizen.publisher", action.publisher)
|
|
36
|
+
if getattr(action, "target", None):
|
|
37
|
+
span.set_attribute("kaizen.target", action.target)
|
|
38
|
+
if verdict.blocked:
|
|
39
|
+
span.set_status(_trace.Status(_trace.StatusCode.ERROR, verdict.reason or "blocked"))
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Kaizen MCP shim: attach the Observer to any MCP agent with zero code change.
|
|
2
|
+
|
|
3
|
+
An MCP client talks to an MCP server over stdio (newline-delimited JSON-RPC).
|
|
4
|
+
This shim sits transparently in the middle: it spawns the real server, forwards
|
|
5
|
+
traffic both ways, and intercepts every `tools/call`. Each call is inspected by
|
|
6
|
+
Kaizen (and reported, so the Observer learns the agent's behavior). A blocked
|
|
7
|
+
call is answered with an MCP error and never reaches the server.
|
|
8
|
+
|
|
9
|
+
Usage (e.g. in an MCP client config, replacing the server command):
|
|
10
|
+
|
|
11
|
+
kaizen-mcp -- uvx some-mcp-server --flag value
|
|
12
|
+
|
|
13
|
+
Config via env: KAIZEN_API_KEY, KAIZEN_AGENT, KAIZEN_BASE_URL.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
|
|
23
|
+
from .client import Kaizen
|
|
24
|
+
from .models import Action
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _kaizen_from_env() -> Kaizen:
|
|
28
|
+
return Kaizen(
|
|
29
|
+
api_key=os.environ.get("KAIZEN_API_KEY"),
|
|
30
|
+
agent=os.environ.get("KAIZEN_AGENT", "mcp-agent"),
|
|
31
|
+
base_url=os.environ.get("KAIZEN_BASE_URL", "https://api.getkaizen.io"),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _block_response(req_id, reason: str) -> dict:
|
|
36
|
+
# An MCP tool result with isError so the model sees a refusal, not a crash.
|
|
37
|
+
return {
|
|
38
|
+
"jsonrpc": "2.0",
|
|
39
|
+
"id": req_id,
|
|
40
|
+
"result": {"content": [{"type": "text", "text": f"Blocked by Kaizen: {reason}"}], "isError": True},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def intercept(line: str, kz: Kaizen):
|
|
45
|
+
"""Decide what to do with one client->server line.
|
|
46
|
+
|
|
47
|
+
Returns (forward, response):
|
|
48
|
+
forward = the line to send on to the real server, or None to drop it
|
|
49
|
+
response = a JSON-RPC dict to send straight back to the client, or None
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
msg = json.loads(line)
|
|
53
|
+
except Exception:
|
|
54
|
+
return line, None # not JSON we understand; pass through untouched
|
|
55
|
+
if isinstance(msg, dict) and msg.get("method") == "tools/call":
|
|
56
|
+
params = msg.get("params") or {}
|
|
57
|
+
action = Action(kind="tool_call", tool=params.get("name"), metadata={"arguments": params.get("arguments")})
|
|
58
|
+
verdict = kz.inspect(action)
|
|
59
|
+
if verdict.blocked:
|
|
60
|
+
return None, _block_response(msg.get("id"), verdict.reason)
|
|
61
|
+
return line, None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run(server_cmd, kaizen: Kaizen = None, stdin=None, stdout=None) -> int:
|
|
65
|
+
kz = kaizen or _kaizen_from_env()
|
|
66
|
+
stdin = stdin or sys.stdin
|
|
67
|
+
stdout = stdout or sys.stdout
|
|
68
|
+
proc = subprocess.Popen(server_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1)
|
|
69
|
+
|
|
70
|
+
# server -> client (verbatim)
|
|
71
|
+
def pump_out():
|
|
72
|
+
for line in proc.stdout:
|
|
73
|
+
stdout.write(line)
|
|
74
|
+
stdout.flush()
|
|
75
|
+
|
|
76
|
+
t = threading.Thread(target=pump_out, daemon=True)
|
|
77
|
+
t.start()
|
|
78
|
+
|
|
79
|
+
# client -> server (intercepted)
|
|
80
|
+
for line in stdin:
|
|
81
|
+
line = line.rstrip("\n")
|
|
82
|
+
if not line:
|
|
83
|
+
continue
|
|
84
|
+
forward, response = intercept(line, kz)
|
|
85
|
+
if response is not None:
|
|
86
|
+
stdout.write(json.dumps(response) + "\n")
|
|
87
|
+
stdout.flush()
|
|
88
|
+
if forward is not None:
|
|
89
|
+
proc.stdin.write(forward + "\n")
|
|
90
|
+
proc.stdin.flush()
|
|
91
|
+
|
|
92
|
+
proc.stdin.close()
|
|
93
|
+
code = proc.wait()
|
|
94
|
+
t.join(timeout=5) # drain remaining server output before returning
|
|
95
|
+
return code
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main():
|
|
99
|
+
argv = sys.argv[1:]
|
|
100
|
+
if "--" in argv:
|
|
101
|
+
argv = argv[argv.index("--") + 1:]
|
|
102
|
+
if not argv:
|
|
103
|
+
sys.stderr.write("usage: kaizen-mcp -- <mcp-server-command> [args...]\n")
|
|
104
|
+
sys.exit(2)
|
|
105
|
+
sys.exit(run(argv))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
main()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field, asdict
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
MODES = {"allowlist", "blocklist", "correlation"}
|
|
7
|
+
DECISIONS = {"allow", "block"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# An Action is what an agent is about to do, the thing we inspect.
|
|
11
|
+
@dataclass
|
|
12
|
+
class Action:
|
|
13
|
+
kind: str = "tool_call" # tool_call | skill_load | connect | file
|
|
14
|
+
tool: Optional[str] = None # tool or skill name
|
|
15
|
+
publisher: Optional[str] = None # who published it
|
|
16
|
+
target: Optional[str] = None # ip, domain, url, or path
|
|
17
|
+
hash: Optional[str] = None # file hash
|
|
18
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict:
|
|
21
|
+
return asdict(self)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Finding:
|
|
26
|
+
kind: str
|
|
27
|
+
value: str
|
|
28
|
+
source: str = "policy"
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict:
|
|
31
|
+
return asdict(self)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# The stable verdict contract: allow or block, with a reason and the evidence.
|
|
35
|
+
@dataclass
|
|
36
|
+
class Verdict:
|
|
37
|
+
decision: str = "allow" # allow | block
|
|
38
|
+
reason: str = ""
|
|
39
|
+
evidence: list[Finding] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def blocked(self) -> bool:
|
|
43
|
+
return self.decision == "block"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def allowed(self) -> bool:
|
|
47
|
+
return self.decision == "allow"
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict:
|
|
50
|
+
return {
|
|
51
|
+
"decision": self.decision,
|
|
52
|
+
"reason": self.reason,
|
|
53
|
+
"evidence": [f.to_dict() for f in self.evidence],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Policy:
|
|
59
|
+
mode: str # allowlist | blocklist | correlation
|
|
60
|
+
rules: dict[str, Any] = field(default_factory=dict)
|
|
61
|
+
name: str = ""
|
|
62
|
+
enabled: bool = True
|
|
63
|
+
|
|
64
|
+
def __post_init__(self):
|
|
65
|
+
# A mistyped mode would silently become a no-op, which for a security
|
|
66
|
+
# control means a silent bypass. Reject it loudly instead.
|
|
67
|
+
if self.mode not in MODES:
|
|
68
|
+
raise ValueError(f"unknown policy mode {self.mode!r}, expected one of {sorted(MODES)}")
|
|
69
|
+
# An empty allowlist would otherwise block everything silently.
|
|
70
|
+
if self.mode == "allowlist":
|
|
71
|
+
r = self.rules or {}
|
|
72
|
+
if not (r.get("publishers") or r.get("tools")):
|
|
73
|
+
raise ValueError("an allowlist policy must list at least one allowed publisher or tool")
|
kaizen_security/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import queue
|
|
5
|
+
import ssl
|
|
6
|
+
import threading
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from .models import Action, Policy, Verdict
|
|
11
|
+
|
|
12
|
+
SCHEMA_VERSION = "1"
|
|
13
|
+
_SSL = ssl.create_default_context()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Talks to the control plane. Standard library only, so the SDK stays
|
|
17
|
+
# dependency free. Verdict reporting goes through one bounded queue drained by a
|
|
18
|
+
# small worker pool, so a slow or unreachable control plane can never spawn
|
|
19
|
+
# unbounded threads or block the agent.
|
|
20
|
+
class Reporter:
|
|
21
|
+
def __init__(self, base_url: str, api_key: str, timeout: float = 3.0, workers: int = 2, max_queue: int = 1000):
|
|
22
|
+
self.base_url = base_url.rstrip("/")
|
|
23
|
+
self.api_key = api_key
|
|
24
|
+
self.timeout = timeout
|
|
25
|
+
self._q: queue.Queue = queue.Queue(maxsize=max_queue)
|
|
26
|
+
self._started = False
|
|
27
|
+
self._workers = workers
|
|
28
|
+
|
|
29
|
+
def _ensure_workers(self) -> None:
|
|
30
|
+
if self._started:
|
|
31
|
+
return
|
|
32
|
+
self._started = True
|
|
33
|
+
for _ in range(self._workers):
|
|
34
|
+
threading.Thread(target=self._drain, daemon=True).start()
|
|
35
|
+
|
|
36
|
+
def _drain(self) -> None:
|
|
37
|
+
while True:
|
|
38
|
+
payload = self._q.get()
|
|
39
|
+
try:
|
|
40
|
+
self._post("/v1/verdicts", payload)
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
finally:
|
|
44
|
+
self._q.task_done()
|
|
45
|
+
|
|
46
|
+
def _headers(self) -> dict:
|
|
47
|
+
return {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
|
48
|
+
|
|
49
|
+
def _post(self, path: str, payload: dict) -> None:
|
|
50
|
+
req = urllib.request.Request(
|
|
51
|
+
self.base_url + path,
|
|
52
|
+
data=json.dumps(payload).encode(),
|
|
53
|
+
headers=self._headers(),
|
|
54
|
+
method="POST",
|
|
55
|
+
)
|
|
56
|
+
urllib.request.urlopen(req, timeout=self.timeout, context=_SSL).close()
|
|
57
|
+
|
|
58
|
+
def send(self, agent: str, action: Action, verdict: Verdict) -> None:
|
|
59
|
+
self._ensure_workers()
|
|
60
|
+
payload = {
|
|
61
|
+
"schema_version": SCHEMA_VERSION,
|
|
62
|
+
"agent": agent,
|
|
63
|
+
"action": action.to_dict(),
|
|
64
|
+
"verdict": verdict.to_dict(),
|
|
65
|
+
}
|
|
66
|
+
try:
|
|
67
|
+
self._q.put_nowait(payload) # drop, never block the agent, if the queue is full
|
|
68
|
+
except queue.Full:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def get_policies(self, agent: str) -> Optional[list[Policy]]:
|
|
72
|
+
try:
|
|
73
|
+
req = urllib.request.Request(
|
|
74
|
+
self.base_url + f"/v1/policy?agent={agent}",
|
|
75
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
76
|
+
)
|
|
77
|
+
with urllib.request.urlopen(req, timeout=self.timeout, context=_SSL) as r:
|
|
78
|
+
data = json.loads(r.read())
|
|
79
|
+
return [
|
|
80
|
+
Policy(
|
|
81
|
+
mode=p["mode"],
|
|
82
|
+
rules=p.get("rules", {}),
|
|
83
|
+
name=p.get("name", ""),
|
|
84
|
+
enabled=p.get("enabled", True),
|
|
85
|
+
)
|
|
86
|
+
for p in data.get("policies", [])
|
|
87
|
+
]
|
|
88
|
+
except Exception:
|
|
89
|
+
return None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kaizen-security
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pluggable enforcement for AI agent actions. Inspect every tool call and block known-bad.
|
|
5
|
+
Author: Kaizen Security
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://getkaizen.io
|
|
8
|
+
Project-URL: Documentation, https://docs.getkaizen.io
|
|
9
|
+
Keywords: ai,agents,security,mcp,guardrails
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Security
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest<9.0,>=8.0; extra == "test"
|
|
23
|
+
Provides-Extra: openai-agents
|
|
24
|
+
Requires-Dist: openai-agents>=0.1; extra == "openai-agents"
|
|
25
|
+
Provides-Extra: langchain
|
|
26
|
+
Requires-Dist: langchain-core>=0.1; extra == "langchain"
|
|
27
|
+
Provides-Extra: opentelemetry
|
|
28
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == "opentelemetry"
|
|
29
|
+
|
|
30
|
+
# kaizen-security
|
|
31
|
+
|
|
32
|
+
Pluggable enforcement for AI agent actions. Inspect every tool call, skill load, or outbound connection, and block the known-bad before it reaches your data. Zero runtime dependencies.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install kaizen-security
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quickstart
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from kaizen_security import Kaizen
|
|
44
|
+
|
|
45
|
+
kz = Kaizen(api_key="kz_live_...") # syncs policy from the control plane
|
|
46
|
+
|
|
47
|
+
verdict = kz.inspect(tool="clawhub2", publisher="hightower6eu", target="91.92.242.30")
|
|
48
|
+
if verdict.blocked:
|
|
49
|
+
print(verdict.reason) # blocked by policy: blacklisted publisher, ...
|
|
50
|
+
for f in verdict.evidence:
|
|
51
|
+
print(f.kind, f.value)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Raise on a block instead of branching:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from kaizen_security import KaizenBlocked
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
kz.enforce(tool="clawhub2", publisher="hightower6eu")
|
|
61
|
+
except KaizenBlocked as e:
|
|
62
|
+
handle(e.verdict)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Wrap a tool function:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
@kz.guard
|
|
69
|
+
def call_tool(name, **kwargs):
|
|
70
|
+
...
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Run it fully local, no account
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from kaizen_security import Kaizen, Policy
|
|
77
|
+
|
|
78
|
+
policy = Policy(mode="blocklist", rules={
|
|
79
|
+
"publishers": ["hightower6eu"],
|
|
80
|
+
"ips": ["91.92.242.30"],
|
|
81
|
+
"skill_patterns": [r"^clawhub[0-9]*$"],
|
|
82
|
+
})
|
|
83
|
+
kz = Kaizen(policies=[policy], report=False)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## The contract
|
|
87
|
+
|
|
88
|
+
`inspect(action) -> Verdict(decision, reason, evidence)` where `decision` is `allow` or `block`. Enforcement runs locally for low latency. When an `api_key` is set, the client syncs policy from the control plane and reports verdicts back for the dashboard, fire and forget so it never adds latency.
|
|
89
|
+
|
|
90
|
+
## Modes
|
|
91
|
+
|
|
92
|
+
- `blocklist`: block on a match against blacklisted publishers, IPs, domains, skill patterns, or hashes.
|
|
93
|
+
- `allowlist`: allow only approved publishers or tools, block the rest.
|
|
94
|
+
- `correlation`: flag a risky session sequence, for example a sensitive read followed by an outbound connect.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
kaizen_security/__init__.py,sha256=N6o5303FJKuQplX9_T46Wyn_eFApb65h7Wdlqkm1tJU,198
|
|
2
|
+
kaizen_security/client.py,sha256=5J12Pmce4XP727pjh6_gQ_nE4eGu1LK_ssQBrYuYvmY,5329
|
|
3
|
+
kaizen_security/engine.py,sha256=1E1NRdK54hz534MeBhW1XGVkOfc9NIvWJqnySPlPY_s,5053
|
|
4
|
+
kaizen_security/mcp_shim.py,sha256=3EPT4Adv6Ill-b5eQW_lqM10OAm277Ed-AUXDl9vCOc,3512
|
|
5
|
+
kaizen_security/models.py,sha256=PVypeNKZGyqt3HY9VNZvqCi0HV6v-qk-zEYTVF5Eqyc,2288
|
|
6
|
+
kaizen_security/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
kaizen_security/report.py,sha256=7adLmcGLqGoB0eIJy71fehploMyLy4WW9n7UbzYNIdM,3013
|
|
8
|
+
kaizen_security/integrations/__init__.py,sha256=tOJRNEslqI1f2hgOGwFbHJISVMQ_F_JpNcdhl5GgWis,72
|
|
9
|
+
kaizen_security/integrations/langchain.py,sha256=ABMBR9Yjn3rPJaHgKgvobFaL9YykDboM58_7eUT5LA0,2290
|
|
10
|
+
kaizen_security/integrations/openai_agents.py,sha256=3xD8TmtERDwf7ITqbFKBCJDA-pUoMXRh4_XknN7wscM,2084
|
|
11
|
+
kaizen_security/integrations/otel.py,sha256=hY3NvhI4o0sh4pk53CvzfA5CXwKx2VFlvAiAGbQvbAA,1645
|
|
12
|
+
kaizen_security-0.1.0.dist-info/METADATA,sha256=2JnRJow7Eg1ZdurJsI-lnEIDVtYzXOtxYbqhzIMUDNg,3050
|
|
13
|
+
kaizen_security-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
14
|
+
kaizen_security-0.1.0.dist-info/entry_points.txt,sha256=UVdKCfjWsTGJ4Yxmv0ko07H8NiCpGFaVn2RVxjgCrf8,61
|
|
15
|
+
kaizen_security-0.1.0.dist-info/top_level.txt,sha256=3xsiBAUi_Ny28MZveTWIfV_rStUjriEuJbPU63XAQbo,16
|
|
16
|
+
kaizen_security-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kaizen_security
|