kaizen-security 0.1.0__tar.gz

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.
Files changed (27) hide show
  1. kaizen_security-0.1.0/PKG-INFO +94 -0
  2. kaizen_security-0.1.0/README.md +65 -0
  3. kaizen_security-0.1.0/kaizen_security/__init__.py +5 -0
  4. kaizen_security-0.1.0/kaizen_security/client.py +141 -0
  5. kaizen_security-0.1.0/kaizen_security/engine.py +147 -0
  6. kaizen_security-0.1.0/kaizen_security/integrations/__init__.py +1 -0
  7. kaizen_security-0.1.0/kaizen_security/integrations/langchain.py +62 -0
  8. kaizen_security-0.1.0/kaizen_security/integrations/openai_agents.py +57 -0
  9. kaizen_security-0.1.0/kaizen_security/integrations/otel.py +39 -0
  10. kaizen_security-0.1.0/kaizen_security/mcp_shim.py +109 -0
  11. kaizen_security-0.1.0/kaizen_security/models.py +73 -0
  12. kaizen_security-0.1.0/kaizen_security/py.typed +0 -0
  13. kaizen_security-0.1.0/kaizen_security/report.py +89 -0
  14. kaizen_security-0.1.0/kaizen_security.egg-info/PKG-INFO +94 -0
  15. kaizen_security-0.1.0/kaizen_security.egg-info/SOURCES.txt +25 -0
  16. kaizen_security-0.1.0/kaizen_security.egg-info/dependency_links.txt +1 -0
  17. kaizen_security-0.1.0/kaizen_security.egg-info/entry_points.txt +2 -0
  18. kaizen_security-0.1.0/kaizen_security.egg-info/requires.txt +12 -0
  19. kaizen_security-0.1.0/kaizen_security.egg-info/top_level.txt +1 -0
  20. kaizen_security-0.1.0/pyproject.toml +45 -0
  21. kaizen_security-0.1.0/setup.cfg +4 -0
  22. kaizen_security-0.1.0/tests/test_engine.py +78 -0
  23. kaizen_security-0.1.0/tests/test_hardening.py +133 -0
  24. kaizen_security-0.1.0/tests/test_langchain.py +35 -0
  25. kaizen_security-0.1.0/tests/test_mcp_shim.py +57 -0
  26. kaizen_security-0.1.0/tests/test_openai_agents.py +45 -0
  27. kaizen_security-0.1.0/tests/test_otel.py +29 -0
@@ -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,65 @@
1
+ # kaizen-security
2
+
3
+ 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.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install kaizen-security
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from kaizen_security import Kaizen
15
+
16
+ kz = Kaizen(api_key="kz_live_...") # syncs policy from the control plane
17
+
18
+ verdict = kz.inspect(tool="clawhub2", publisher="hightower6eu", target="91.92.242.30")
19
+ if verdict.blocked:
20
+ print(verdict.reason) # blocked by policy: blacklisted publisher, ...
21
+ for f in verdict.evidence:
22
+ print(f.kind, f.value)
23
+ ```
24
+
25
+ Raise on a block instead of branching:
26
+
27
+ ```python
28
+ from kaizen_security import KaizenBlocked
29
+
30
+ try:
31
+ kz.enforce(tool="clawhub2", publisher="hightower6eu")
32
+ except KaizenBlocked as e:
33
+ handle(e.verdict)
34
+ ```
35
+
36
+ Wrap a tool function:
37
+
38
+ ```python
39
+ @kz.guard
40
+ def call_tool(name, **kwargs):
41
+ ...
42
+ ```
43
+
44
+ ## Run it fully local, no account
45
+
46
+ ```python
47
+ from kaizen_security import Kaizen, Policy
48
+
49
+ policy = Policy(mode="blocklist", rules={
50
+ "publishers": ["hightower6eu"],
51
+ "ips": ["91.92.242.30"],
52
+ "skill_patterns": [r"^clawhub[0-9]*$"],
53
+ })
54
+ kz = Kaizen(policies=[policy], report=False)
55
+ ```
56
+
57
+ ## The contract
58
+
59
+ `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.
60
+
61
+ ## Modes
62
+
63
+ - `blocklist`: block on a match against blacklisted publishers, IPs, domains, skill patterns, or hashes.
64
+ - `allowlist`: allow only approved publishers or tools, block the rest.
65
+ - `correlation`: flag a risky session sequence, for example a sensitive read followed by an outbound connect.
@@ -0,0 +1,5 @@
1
+ from .client import Kaizen, KaizenBlocked
2
+ from .models import Action, Finding, Policy, Verdict
3
+
4
+ __all__ = ["Kaizen", "KaizenBlocked", "Action", "Finding", "Policy", "Verdict"]
5
+ __version__ = "0.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"))