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.
@@ -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"))
@@ -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")
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kaizen-mcp = kaizen_security.mcp_shim:main
@@ -0,0 +1 @@
1
+ kaizen_security