shield-kya 0.17.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.
shield_kya/__init__.py ADDED
@@ -0,0 +1,383 @@
1
+ """In-process kya governance shim for Python agent frameworks.
2
+
3
+ Delegates evaluation to the local kya CLI (`kya wrap --offline --json ...`),
4
+ so Python tools share the same offline verdict path as the TypeScript SDK.
5
+ Shipped adapters live in :mod:`shield_kya.integrations` (LangChain/LangGraph
6
+ middleware, Pydantic AI capability, CrewAI global hook, Google ADK plugin,
7
+ Agent Framework middleware, OpenAI Agents SDK guardrail, per-tool wrappers).
8
+ Stdlib only.
9
+
10
+ OSS semantics match `kya hook`: the shim is advisory and FAILS OPEN. A missing
11
+ CLI, a timeout, non-JSON output, an unknown exit code, an empty verdict or any
12
+ other shim error allows the call (first event logged at WARNING, every event at
13
+ DEBUG). Only the local never-list blocks: a DENY verdict from the CLI, or, when
14
+ there is no verdict, the same name-only never-list matched in Python. Pass
15
+ ``fail_closed=True`` (or set ``KYA_FAIL_CLOSED`` to 1/true/yes/on, hosted) to
16
+ deny on those errors instead. ``strict=True`` also blocks REQUIRE_APPROVE.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import functools
23
+ import inspect
24
+ import json
25
+ import logging
26
+ import os
27
+ import signal
28
+ import subprocess
29
+ from dataclasses import dataclass
30
+
31
+ from shield_kya._never_list import js_trim, matches_never_list
32
+
33
+ __version__ = "0.17.0"
34
+ __all__ = ["DEFAULT_TIMEOUT_S", "Decision", "KyaDenied", "adecide", "decide",
35
+ "evaluate", "governed"]
36
+
37
+ # Same budget as `kya hook` (4 s, under a host's 5 s hook limit).
38
+ DEFAULT_TIMEOUT_S = 4.0
39
+
40
+ log = logging.getLogger("shield_kya")
41
+ _warned = False
42
+
43
+
44
+ class KyaDenied(Exception):
45
+ """Raised when kya denies a tool call; the wrapped function never ran."""
46
+
47
+ def __init__(self, tool_id: str, verdict: str, reason_code: str) -> None:
48
+ super().__init__(f"kya {verdict}: {tool_id} ({reason_code or 'no reason'})")
49
+ self.tool_id = tool_id
50
+ self.verdict = verdict
51
+ self.reason_code = reason_code
52
+
53
+
54
+ class KyaUnavailable(RuntimeError):
55
+ """The kya CLI gave no usable verdict (missing, timeout, bad output)."""
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Decision:
60
+ """Outcome of one governed tool call."""
61
+
62
+ allowed: bool
63
+ verdict: str # ALLOW / DENY / REQUIRE_APPROVE / FAIL_OPEN / FAIL_CLOSED
64
+ reason_code: str
65
+ tool_id: str
66
+
67
+ @property
68
+ def reason(self) -> str:
69
+ """Readable reason, suitable to hand back to the model on a deny."""
70
+ if self.verdict == "FAIL_CLOSED":
71
+ return f"KYA fail-closed: {self.tool_id} denied ({self.reason_code})"
72
+ if self.verdict == "REQUIRE_APPROVE":
73
+ return f"KYA strict mode: {self.tool_id} requires approval ({self.reason_code})"
74
+ return f"KYA denied {self.tool_id}: {self.reason_code or self.verdict}"
75
+
76
+ def raise_if_denied(self) -> None:
77
+ if not self.allowed:
78
+ raise KyaDenied(self.tool_id, self.verdict, self.reason_code)
79
+
80
+
81
+ def _env_flag(name: str) -> bool:
82
+ return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
83
+
84
+
85
+ def _abs_path_entries() -> list:
86
+ """PATH entries that are absolute; empty, ".", "~" and other relative ones dropped."""
87
+ return [d for d in os.environ.get("PATH", "").split(os.pathsep) if d and os.path.isabs(d)]
88
+
89
+
90
+ def _find_on_path(name: str) -> str | None:
91
+ """Look `name` up in ABSOLUTE PATH entries only.
92
+
93
+ Never the cwd: shutil.which() on Windows searches the current directory
94
+ first, so a cloned repo could plant kya.exe. Empty, "." and other relative
95
+ entries are skipped on every OS.
96
+ """
97
+ exts = [""]
98
+ if os.name == "nt":
99
+ exts = [e for e in os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";") if e]
100
+ for d in _abs_path_entries():
101
+ for ext in exts:
102
+ candidate = os.path.join(d, name + ext)
103
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
104
+ return candidate
105
+ return None
106
+
107
+
108
+ def _cli() -> list:
109
+ override = os.environ.get("KYA_CLI")
110
+ if override:
111
+ # Must be absolute: a relative value would resolve against the agent's cwd.
112
+ if not os.path.isabs(override) or not os.path.isfile(override):
113
+ raise KyaUnavailable("KYA_CLI ignored: not an absolute path to a file")
114
+ if override.lower().endswith(".js"):
115
+ node = _find_on_path("node")
116
+ if node is None:
117
+ raise KyaUnavailable("KYA_CLI points at a .js file but node is not on PATH")
118
+ return [node, override]
119
+ exe = override
120
+ else:
121
+ found = _find_on_path("kya")
122
+ if found is None:
123
+ raise KyaUnavailable(
124
+ "kya CLI not found on PATH - install @shield-agent/kya "
125
+ "(npm i -g @shield-agent/kya) or set KYA_CLI"
126
+ )
127
+ exe = found
128
+ if os.name == "nt" and exe.lower().endswith((".cmd", ".bat")):
129
+ # cmd.exe re-parses argv, and the args JSON is model-controlled.
130
+ raise KyaUnavailable(
131
+ "refusing to run kya through a .cmd/.bat shim on Windows; set KYA_CLI "
132
+ "to the package's dist/cli.js (run with node)"
133
+ )
134
+ return [exe]
135
+
136
+
137
+ # The CLI child gets an ALLOWLIST of the environment, never the rest: a framework may
138
+ # have loaded a repo .env into os.environ (crewai runs load_dotenv() at import), and
139
+ # variables like NODE_OPTIONS, OPENSSL_CONF or LD_PRELOAD run repo code in node, while
140
+ # KYA_* ones redirect the trail or add a webhook. TMPDIR is left out on purpose.
141
+ _CHILD_ENV = ("HOME", "USERPROFILE", "LANG", "TZ")
142
+ _CHILD_ENV_NT = ("SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "APPDATA", "LOCALAPPDATA",
143
+ "TEMP", "TMP")
144
+ # Privacy opt-outs: forwarded only when they switch something OFF, so a planted value
145
+ # can only make the trail more private.
146
+ _PRIVACY_OPT_OUTS = ("KYA_DIFF_PREVIEW", "KYA_SEND_PREVIEWS")
147
+ _OFF = ("0", "false", "no", "off")
148
+
149
+
150
+ def _env() -> dict:
151
+ # The npm/brew launcher is `#!/usr/bin/env node`: the child must not find a
152
+ # repo-planted ./node through a relative PATH entry either.
153
+ dirs = _abs_path_entries() or [d for d in os.defpath.split(os.pathsep) if os.path.isabs(d)]
154
+ keep = _CHILD_ENV + (_CHILD_ENV_NT if os.name == "nt" else ())
155
+ env = {k: v for k, v in os.environ.items()
156
+ if k in keep or k.startswith("LC_")
157
+ or (k in _PRIVACY_OPT_OUTS and v.strip().lower() in _OFF)}
158
+ return {**env, "PATH": os.pathsep.join(dirs), "KYA_SKIP_NODE_CHECK": "1"}
159
+
160
+
161
+ def _kill(proc) -> None:
162
+ """Kill the CLI and anything it spawned (own session on POSIX)."""
163
+ try:
164
+ if os.name == "posix":
165
+ os.killpg(proc.pid, signal.SIGKILL)
166
+ else:
167
+ proc.kill()
168
+ except (ProcessLookupError, PermissionError):
169
+ pass
170
+
171
+
172
+ def _parse(returncode: int, stdout: str, stderr: str) -> dict:
173
+ try:
174
+ data = json.loads(stdout)
175
+ except json.JSONDecodeError as exc:
176
+ raise KyaUnavailable(
177
+ f"kya returned non-JSON output (exit {returncode}): "
178
+ f"{stderr.strip()[:200] or stdout[:200]}"
179
+ ) from exc
180
+ # 0=allow, 1=deny, 4=hold - anything else is a transport/infra failure,
181
+ # not a policy verdict.
182
+ if returncode not in (0, 1, 4) or not isinstance(data, dict):
183
+ raise KyaUnavailable(
184
+ f"kya exited {returncode} (transport failure, not a verdict): "
185
+ f"{stderr.strip()[:200]}"
186
+ )
187
+ return data
188
+
189
+
190
+ def _run_kya(argv: list, timeout: float = DEFAULT_TIMEOUT_S) -> dict:
191
+ try:
192
+ proc = subprocess.Popen(
193
+ [*_cli(), *argv], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
194
+ stderr=subprocess.PIPE, env=_env(), start_new_session=True,
195
+ encoding="utf-8", errors="replace")
196
+ except OSError as exc:
197
+ raise KyaUnavailable(f"kya could not start: {exc}") from exc
198
+ try:
199
+ out, err = proc.communicate(timeout=timeout)
200
+ except subprocess.TimeoutExpired as exc:
201
+ _kill(proc)
202
+ proc.communicate()
203
+ raise KyaUnavailable(f"kya timed out after {timeout:g}s") from exc
204
+ except BaseException:
205
+ _kill(proc)
206
+ proc.wait()
207
+ raise
208
+ return _parse(proc.returncode, out, err)
209
+
210
+
211
+ async def _arun_kya(argv: list, timeout: float = DEFAULT_TIMEOUT_S) -> dict:
212
+ try:
213
+ proc = await asyncio.create_subprocess_exec(
214
+ *_cli(), *argv, stdin=asyncio.subprocess.DEVNULL,
215
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
216
+ env=_env(), start_new_session=True)
217
+ except OSError as exc:
218
+ raise KyaUnavailable(f"kya could not start: {exc}") from exc
219
+ try:
220
+ out, err = await asyncio.wait_for(proc.communicate(), timeout)
221
+ except asyncio.TimeoutError as exc:
222
+ raise KyaUnavailable(f"kya timed out after {timeout:g}s") from exc
223
+ finally:
224
+ # Timeout or cancellation: never leave the CLI running behind us.
225
+ if proc.returncode is None:
226
+ _kill(proc)
227
+ await asyncio.shield(proc.wait())
228
+ return _parse(proc.returncode or 0, out.decode("utf-8", errors="replace"),
229
+ err.decode("utf-8", errors="replace"))
230
+
231
+
232
+ def _json_safe(value):
233
+ try:
234
+ json.dumps(value, allow_nan=False) # NaN/Infinity is not JSON; the CLI rejects it
235
+ return value
236
+ except RecursionError:
237
+ return f"<unserializable {type(value).__name__}>"
238
+ except Exception:
239
+ pass
240
+ try:
241
+ return repr(value)
242
+ except Exception:
243
+ return f"<unserializable {type(value).__name__}>"
244
+
245
+
246
+ def _bound_args(fn, args, kwargs) -> dict:
247
+ """Bound call arguments for `kya wrap --args`; never raises."""
248
+ try:
249
+ bound = inspect.signature(fn).bind(*args, **kwargs)
250
+ return {k: _json_safe(v) for k, v in bound.arguments.items()}
251
+ except Exception:
252
+ return {}
253
+
254
+
255
+ def _full_tool_id(tool_id: str, server: str | None) -> str:
256
+ tid = js_trim(str(tool_id))
257
+ return f"{js_trim(str(server))}__{tid}" if server else tid
258
+
259
+
260
+ def _wrap_argv(full_id: str, args: dict | None, irreversible: bool) -> list:
261
+ projected = {str(k): _json_safe(v) for k, v in (args or {}).items()}
262
+ # --flag=value: a tool id starting with "-" must not read as a flag.
263
+ argv = ["wrap", "--offline", "--json", f"--tool-id={full_id}",
264
+ f"--args={json.dumps(projected, allow_nan=False)}"]
265
+ if irreversible:
266
+ argv.append("--irreversible")
267
+ return argv
268
+
269
+
270
+ def _fail_closed(flag: bool | None) -> bool:
271
+ return flag if flag is not None else _env_flag("KYA_FAIL_CLOSED")
272
+
273
+
274
+ def _unavailable(full_id: str, message: str, fail_closed: bool | None) -> Decision:
275
+ """No verdict from the CLI. The never-list still blocks in every mode."""
276
+ global _warned
277
+ log.debug("KYA no verdict for %s: %s", full_id, message)
278
+ if matches_never_list(full_id):
279
+ log.warning("KYA no verdict for %s, never-list match, denying: %s", full_id, message)
280
+ return Decision(False, "DENY", "NEVER_EVENT", full_id)
281
+ if _fail_closed(fail_closed):
282
+ log.warning("KYA fail-closed, denying %s: %s", full_id, message)
283
+ return Decision(False, "FAIL_CLOSED", "KYA_UNAVAILABLE", full_id)
284
+ if not _warned:
285
+ _warned = True
286
+ log.warning("KYA advisory mode, allowing tool calls without a verdict: %s", message)
287
+ return Decision(True, "FAIL_OPEN", "KYA_UNAVAILABLE", full_id)
288
+
289
+
290
+ def _decision(full_id: str, result: dict, strict: bool,
291
+ fail_closed: bool | None) -> Decision:
292
+ # Defense in depth: a hostile or stale CLI (KYA_CLI can come from a .env that a
293
+ # framework loads, e.g. crewai's import-time load_dotenv) cannot ALLOW a never-list tool.
294
+ if matches_never_list(full_id):
295
+ return Decision(False, "DENY", "NEVER_EVENT", full_id)
296
+ response = (result.get("eval") or {}).get("response") or {}
297
+ verdict = str(response.get("verdict", "")).upper()
298
+ reason = str(response.get("reasonCode", ""))
299
+ if verdict == "DENY":
300
+ return Decision(False, verdict, reason, full_id)
301
+ if verdict == "REQUIRE_APPROVE":
302
+ return Decision(not strict, verdict, reason, full_id)
303
+ if verdict == "ALLOW":
304
+ return Decision(True, verdict, reason, full_id)
305
+ return _unavailable(full_id, f"kya returned no usable verdict ({verdict or 'empty'})",
306
+ fail_closed)
307
+
308
+
309
+ # Same as the CLI's offline evaluate (baseTier): an empty tool id is DENY EMPTY_TOOL.
310
+ _EMPTY = Decision(False, "DENY", "EMPTY_TOOL", "")
311
+
312
+
313
+ def decide(tool_id: str, args: dict | None = None, *, server: str | None = None,
314
+ irreversible: bool = False, strict: bool = False,
315
+ fail_closed: bool | None = None,
316
+ timeout: float = DEFAULT_TIMEOUT_S) -> Decision:
317
+ """Evaluate one tool call via `kya wrap` (records the trail); never raises."""
318
+ full_id = _full_tool_id(tool_id, server)
319
+ if not full_id:
320
+ return _EMPTY
321
+ try:
322
+ result = _run_kya(_wrap_argv(full_id, args, irreversible), timeout)
323
+ return _decision(full_id, result, strict, fail_closed)
324
+ except Exception as exc: # any shim/CLI error: fail open (or closed), never crash the agent
325
+ return _unavailable(full_id, str(exc) or type(exc).__name__, fail_closed)
326
+
327
+
328
+ async def adecide(tool_id: str, args: dict | None = None, *, server: str | None = None,
329
+ irreversible: bool = False, strict: bool = False,
330
+ fail_closed: bool | None = None,
331
+ timeout: float = DEFAULT_TIMEOUT_S) -> Decision:
332
+ """Async :func:`decide`: runs the CLI as an asyncio subprocess, never blocks the loop."""
333
+ full_id = _full_tool_id(tool_id, server)
334
+ if not full_id:
335
+ return _EMPTY
336
+ try:
337
+ result = await _arun_kya(_wrap_argv(full_id, args, irreversible), timeout)
338
+ return _decision(full_id, result, strict, fail_closed)
339
+ except Exception as exc: # any shim/CLI error: fail open (or closed), never crash the agent
340
+ return _unavailable(full_id, str(exc) or type(exc).__name__, fail_closed)
341
+
342
+
343
+ def evaluate(tool_id: str, args: dict | None = None,
344
+ timeout: float = DEFAULT_TIMEOUT_S) -> dict:
345
+ """Evaluate a tool call offline; returns the parsed eval-tool JSON dict.
346
+
347
+ A manual check, not a gate: CLI problems raise RuntimeError.
348
+ """
349
+ argv = ["eval-tool", "--offline", "--json", f"--tool-id={tool_id}",
350
+ f"--args={json.dumps(args or {})}"]
351
+ return _run_kya(argv, timeout)
352
+
353
+
354
+ def governed(tool_id: str, server: str | None = None, irreversible: bool = False, *,
355
+ strict: bool = False, fail_closed: bool | None = None,
356
+ timeout: float = DEFAULT_TIMEOUT_S):
357
+ """Decorator: govern the wrapped callable (sync or async) at the tool boundary.
358
+
359
+ DENY raises KyaDenied without calling it (a plain function has no other
360
+ way to report a deny). ALLOW and REQUIRE_APPROVE run it; ``strict=True``
361
+ blocks REQUIRE_APPROVE too. CLI errors fail open unless ``fail_closed``.
362
+ Async functions are checked with :func:`adecide`, off the event loop.
363
+ """
364
+ opts = dict(server=server, irreversible=irreversible, strict=strict,
365
+ fail_closed=fail_closed, timeout=timeout)
366
+
367
+ def decorator(fn):
368
+ if inspect.iscoroutinefunction(fn):
369
+ @functools.wraps(fn)
370
+ async def awrapper(*args, **kwargs):
371
+ (await adecide(tool_id, _bound_args(fn, args, kwargs), **opts)).raise_if_denied()
372
+ return await fn(*args, **kwargs)
373
+
374
+ return awrapper
375
+
376
+ @functools.wraps(fn)
377
+ def wrapper(*args, **kwargs):
378
+ decide(tool_id, _bound_args(fn, args, kwargs), **opts).raise_if_denied()
379
+ return fn(*args, **kwargs)
380
+
381
+ return wrapper
382
+
383
+ return decorator
@@ -0,0 +1,94 @@
1
+ """Port of `matchesNeverList` (sdks/kya/src/offline-evaluate.ts).
2
+
3
+ Used only when the kya CLI gives no verdict, so the never-list keeps blocking
4
+ in fail-open mode (same as the `kya hook` error path). Depends on the tool
5
+ name alone. tests/test_never_list.py asserts this module against the shared
6
+ vectors in sdks/kya/test/fixtures/never-list-vectors.json, which the TS suite
7
+ asserts too, so the two lists cannot drift silently.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ # SAMPLE_TOOLS with policyTier DENY (sample-tools.ts).
15
+ DENY_SAMPLE_IDS = frozenset({"org.sample.never.event"})
16
+
17
+ # Normalized MCP server ids and aliases (mcp-servers.ts mcpServerKeys()).
18
+ MCP_SERVER_KEYS = frozenset({
19
+ "atlassian", "brave", "brave_search", "browser_use", "browseruse",
20
+ "chrome_devtools", "chromedevtools", "confluence", "context7", "devtools",
21
+ "exa", "fetch", "figma", "filesystem", "firecrawl", "fs", "github", "jira",
22
+ "linear", "ms_playwright", "n8n", "notion", "playwright", "postgres",
23
+ "postgresql", "sentry", "sequential_thinking", "sequentialthinking",
24
+ "serena", "slack", "supabase", "zapier",
25
+ })
26
+
27
+ # NEVER_LIST_VERBS (offline-evaluate.ts).
28
+ NEVER_LIST_VERBS = ("drop", "truncate", "purge", "transfer")
29
+
30
+ # Exactly what JS String.prototype.trim() removes: WhiteSpace (tab, VT, FF,
31
+ # space, NBSP, BOM, Zs) and LineTerminator (LF, CR, U+2028, U+2029). Python's
32
+ # str.strip() differs (it strips \x1c-\x1f and \x85, keeps the BOM).
33
+ JS_TRIM = ("\t\n\x0b\x0c\r \xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006"
34
+ "\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff")
35
+
36
+ _DESTRUCTIVE = re.compile("|".join(NEVER_LIST_VERBS))
37
+
38
+
39
+ def _is_mcp_start_char(c: str) -> bool:
40
+ return "a" <= c <= "z" or "0" <= c <= "9"
41
+
42
+
43
+ def _is_mcp_id_char(c: str) -> bool:
44
+ return _is_mcp_start_char(c) or c == "_" or c == "-"
45
+
46
+
47
+ def _split_mcp_body(body: str) -> tuple[str, str] | None:
48
+ # Linear scan replacing the old lazy-regex split (CodeQL
49
+ # js/polynomial-redos on the TS twin). Reproduces the original pattern
50
+ # ^(?:mcp__)?([a-z0-9][a-z0-9_-]*?)(?:__|\.|\/)([a-z0-9][a-z0-9_-]*)$
51
+ # exactly, capture groups included: the first separator whose left side is
52
+ # a valid server and whose right side is a valid tool wins. `.`/`/` end the
53
+ # server candidate range, so the scan stops there. Mirror of splitMcpBody
54
+ # in sdks/kya/src/mcp-servers.ts; keep the two in lockstep.
55
+ n = len(body)
56
+ if n == 0 or not _is_mcp_start_char(body[0]):
57
+ return None
58
+ # suffix_ok[j]: every char from j to the end is an id char (one backward pass).
59
+ suffix_ok = [True] * (n + 1)
60
+ for j in range(n - 1, -1, -1):
61
+ suffix_ok[j] = suffix_ok[j + 1] and _is_mcp_id_char(body[j])
62
+ for i, c in enumerate(body):
63
+ sep_len = 2 if c == "_" and i + 1 < n and body[i + 1] == "_" else (1 if c in "./" else 0)
64
+ if sep_len:
65
+ t = i + sep_len
66
+ if t < n and _is_mcp_start_char(body[t]) and suffix_ok[t]:
67
+ return body[:i], body[t:]
68
+ if not _is_mcp_id_char(c):
69
+ break
70
+ return None
71
+
72
+
73
+ def _parse_mcp_id(raw: str) -> tuple[str, str] | None:
74
+ # The optional `mcp__` group was greedy: try the body after the prefix
75
+ # first, then the whole string.
76
+ if raw.startswith("mcp__"):
77
+ return _split_mcp_body(raw[5:]) or _split_mcp_body(raw)
78
+ return _split_mcp_body(raw)
79
+
80
+
81
+ def js_trim(value: str) -> str:
82
+ return value.strip(JS_TRIM)
83
+
84
+
85
+ def matches_never_list(tool_id: str) -> bool:
86
+ raw = js_trim(tool_id).lower()
87
+ # Every sample tool has a policyTier; only the DENY ones match. No sample
88
+ # id parses as an MCP id, so checking the DENY set alone is equivalent.
89
+ if raw in DENY_SAMPLE_IDS:
90
+ return True
91
+ parsed = _parse_mcp_id(raw)
92
+ if not parsed or parsed[0].replace("-", "_") not in MCP_SERVER_KEYS:
93
+ return False
94
+ return bool(_DESTRUCTIVE.search(parsed[1]))
@@ -0,0 +1,109 @@
1
+ """Optional per-framework shims.
2
+
3
+ - :mod:`shield_kya.integrations.langchain`: ``KyaMiddleware`` for
4
+ LangChain ``create_agent`` and LangGraph ``ToolNode`` (agent-wide; a DENY
5
+ goes back to the model as an error ``ToolMessage``).
6
+ - :mod:`shield_kya.integrations.pydantic_ai`: ``KyaCapability`` (agent-wide;
7
+ a DENY becomes the tool result via ``SkipToolExecution``).
8
+ - :mod:`shield_kya.integrations.crewai`: ``register()`` (global
9
+ ``PRE_TOOL_CALL`` hook; a DENY blocks via ``HookAborted``).
10
+ - :mod:`shield_kya.integrations.adk`: ``KyaPlugin`` for Google ADK
11
+ (Runner-wide; a DENY becomes the tool result ``{"error": reason}``).
12
+ - :mod:`shield_kya.integrations.agent_framework`: ``KyaMiddleware`` for
13
+ Microsoft Agent Framework (a ``FunctionMiddleware``; a DENY becomes the
14
+ function result).
15
+ - :mod:`shield_kya.integrations.openai_agents`: ``attach(agent)`` puts a
16
+ KYA tool input guardrail on every function tool and MCP server of an
17
+ OpenAI Agents SDK agent (a DENY becomes the tool output via
18
+ ``reject_content``).
19
+ - :mod:`shield_kya.integrations.langgraph` and the ``governed_tool`` /
20
+ ``wrap_tool(s)`` helpers: per-tool wrappers (a DENY raises ``KyaDenied``).
21
+
22
+ Every adapter delegates verdicts to the kya CLI exactly like
23
+ :func:`shield_kya.decide` (fail-open by default) and never imports its
24
+ framework at package import time.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from collections.abc import Mapping
30
+
31
+ import shield_kya
32
+ from shield_kya import KyaDenied
33
+ from shield_kya._never_list import matches_never_list
34
+
35
+ __all__ = ["KyaDenied", "gate", "langgraph", "crewai", "pydantic_ai", "adk",
36
+ "agent_framework", "openai_agents"]
37
+
38
+
39
+ def _mcp_names(exposed: str, original=None, local=None) -> list:
40
+ """The exposed tool name, then never-list candidates the framework still knows.
41
+
42
+ A framework that renames an MCP tool to ``<prefix>_<local>`` keeps the
43
+ original MCP name: check it, and ``<prefix>__<original>`` (frameworks prefix
44
+ with one underscore, the never-list needs two). Agent Framework strips
45
+ leading ``_.-`` from the local name in the exposed one, so the prefix is
46
+ found with the stripped form and joined to the raw, local and stripped names.
47
+ """
48
+ names = [exposed]
49
+ original = str(original or "")
50
+ local = str(local or original)
51
+ if original and original != exposed:
52
+ names.append(original)
53
+ stem = local.lstrip("_.-")
54
+ if stem and exposed.endswith("_" + stem) and len(exposed) > len(stem) + 1:
55
+ prefix = exposed[:-len(stem) - 1]
56
+ names += [f"{prefix}__{n}" for n in (original, local, stem)]
57
+ return list(dict.fromkeys(names))
58
+
59
+
60
+ async def _adecide_names(get_names, args, opts: dict):
61
+ """:func:`shield_kya.adecide` for one call; returns ``(decision, reason)``, never raises.
62
+
63
+ ``get_names()`` gives the exposed name first, then other names the framework
64
+ keeps for the tool. If any is on the never-list that one is evaluated (and
65
+ denied). Every name is matched with the user's ``server=`` prefix and
66
+ without it: the framework already knows these names, and a prefix the
67
+ never-list does not know (``server="acme"``) must not hide
68
+ ``postgres__drop_table``. An unprefixed match is evaluated without ``server``.
69
+ Any error here, including in ``get_names``, goes through the fail-open /
70
+ never-list / fail-closed rules, never to the framework.
71
+ """
72
+ server = opts.get("server")
73
+ target = name = ""
74
+ srv = server
75
+ try:
76
+ names = [str(n) for n in get_names()]
77
+ target = name = names[0]
78
+ never = [(n, server) for n in names
79
+ if matches_never_list(shield_kya._full_tool_id(n, server))]
80
+ if server:
81
+ never += [(n, None) for n in names
82
+ if matches_never_list(shield_kya._full_tool_id(n, None))]
83
+ target, srv = never[0] if never else (name, server)
84
+ args = dict(args) if isinstance(args, Mapping) else {"input": args}
85
+ decision = await shield_kya.adecide(target, args, **{**opts, "server": srv})
86
+ except Exception as exc:
87
+ decision = shield_kya._unavailable(shield_kya._full_tool_id(target, srv), repr(exc),
88
+ opts.get("fail_closed"))
89
+ reason = decision.reason
90
+ if target != name: # the model called the renamed tool: name it too
91
+ reason = (f"KYA denied {name}: its MCP name {target} is on the never-list "
92
+ f"({decision.reason_code})")
93
+ return decision, reason
94
+
95
+
96
+ def gate(tool_id: str, args: dict | None = None, *, server: str | None = None,
97
+ irreversible: bool = False, strict: bool = False,
98
+ fail_closed: bool | None = None,
99
+ timeout: float = shield_kya.DEFAULT_TIMEOUT_S) -> shield_kya.Decision:
100
+ """Evaluate one tool call via `kya wrap`; raise KyaDenied unless allowed.
101
+
102
+ Same semantics as :func:`shield_kya.governed`: ALLOW and REQUIRE_APPROVE
103
+ pass (``strict`` blocks REQUIRE_APPROVE), DENY raises KyaDenied, and CLI
104
+ errors fail open unless ``fail_closed``.
105
+ """
106
+ decision = shield_kya.decide(tool_id, args, server=server, irreversible=irreversible,
107
+ strict=strict, fail_closed=fail_closed, timeout=timeout)
108
+ decision.raise_if_denied()
109
+ return decision
@@ -0,0 +1,92 @@
1
+ """Google ADK adapter: one Runner-wide KYA plugin.
2
+
3
+ from google.adk.apps import App
4
+ from shield_kya.integrations.adk import KyaPlugin
5
+
6
+ app = App(name="my_app", root_agent=agent, plugins=[KyaPlugin()])
7
+
8
+ ``KyaPlugin`` hooks ``before_tool_callback``: every tool call the Runner
9
+ dispatches (sub-agents, ``AgentTool`` with its default ``include_plugins=True``,
10
+ MCP toolsets, long-running tools, live mode and workflow tool nodes included)
11
+ is checked with ``kya wrap``, as an asyncio subprocess, before it executes. A
12
+ DENY returns ``{"error": "<reason>"}``, which ADK uses as the tool result
13
+ instead of running the tool, so the model reads the reason and the run
14
+ continues. ``strict=True`` raises ``KyaDenied`` instead (ADK re-raises it as a
15
+ ``RuntimeError`` and the run aborts) and blocks REQUIRE_APPROVE too. CLI errors
16
+ fail open unless ``fail_closed=True`` / ``KYA_FAIL_CLOSED=1``; the never-list
17
+ blocks either way, also when this plugin's own code fails.
18
+
19
+ The tool id is the name the model called (``tool.name``). For an MCP tool the
20
+ original MCP name is checked against the never-list too, and so is
21
+ ``<tool_name_prefix>__<original name>``: ADK prefixes with one underscore
22
+ (``postgres_drop_table``), which the never-list would not recognize. Put the
23
+ plugin last in ``plugins`` so it sees the args other plugins rewrote.
24
+
25
+ Tested with google-adk 2.11.0. The plugin class is built on first access, so
26
+ this module imports without google-adk.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import shield_kya
32
+ from shield_kya.integrations import _adecide_names, _mcp_names
33
+
34
+ # KyaPlugin is left out of __all__ so a star import works without google-adk.
35
+ __all__: list = []
36
+
37
+
38
+ def _names(tool) -> list:
39
+ """The exposed name, then the original name ADK keeps on an MCP tool."""
40
+ name = str(getattr(tool, "name", "") or "")
41
+ original = None
42
+ # Public raw_mcp_tool (google-adk 2.11.0), else the private field it wraps; a
43
+ # broken one falls through to the next and never drops the exposed name.
44
+ for attr in ("raw_mcp_tool", "_mcp_tool"):
45
+ try:
46
+ original = getattr(getattr(tool, attr, None), "name", None)
47
+ except Exception:
48
+ continue
49
+ if original:
50
+ break
51
+ return _mcp_names(name, original)
52
+
53
+
54
+ def _build_plugin():
55
+ try:
56
+ from google.adk.plugins.base_plugin import BasePlugin
57
+ except ImportError as exc:
58
+ raise ImportError(
59
+ "google-adk>=2.11 is required for KyaPlugin (pip install 'google-adk>=2.11,<3')"
60
+ ) from exc
61
+
62
+ class KyaPlugin(BasePlugin):
63
+ """Gate every tool call of an ADK Runner (register it on the App)."""
64
+
65
+ def __init__(self, *, name: str = "shield_kya", server: str | None = None,
66
+ irreversible: bool = False, strict: bool = False,
67
+ fail_closed: bool | None = None,
68
+ timeout: float = shield_kya.DEFAULT_TIMEOUT_S) -> None:
69
+ super().__init__(name=name)
70
+ self._opts = dict(server=server, irreversible=irreversible, strict=strict,
71
+ fail_closed=fail_closed, timeout=timeout)
72
+
73
+ async def before_tool_callback(self, *, tool, tool_args, tool_context):
74
+ # Anything that escapes this hook becomes a RuntimeError that aborts the
75
+ # run; _adecide_names never raises, so only strict mode raises here.
76
+ decision, reason = await _adecide_names(lambda: _names(tool), tool_args, self._opts)
77
+ if decision.allowed:
78
+ return None
79
+ if self._opts["strict"]:
80
+ decision.raise_if_denied()
81
+ return {"error": reason}
82
+
83
+ KyaPlugin.__module__ = __name__
84
+ return KyaPlugin
85
+
86
+
87
+ def __getattr__(name: str):
88
+ # ponytail: lazy class so the stdlib suite imports this module without google-adk.
89
+ if name == "KyaPlugin":
90
+ cls = globals()["KyaPlugin"] = _build_plugin()
91
+ return cls
92
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")