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 +383 -0
- shield_kya/_never_list.py +94 -0
- shield_kya/integrations/__init__.py +109 -0
- shield_kya/integrations/adk.py +92 -0
- shield_kya/integrations/agent_framework.py +91 -0
- shield_kya/integrations/crewai.py +213 -0
- shield_kya/integrations/langchain.py +78 -0
- shield_kya/integrations/langgraph.py +65 -0
- shield_kya/integrations/openai_agents.py +173 -0
- shield_kya/integrations/pydantic_ai.py +129 -0
- shield_kya-0.17.0.dist-info/METADATA +265 -0
- shield_kya-0.17.0.dist-info/RECORD +14 -0
- shield_kya-0.17.0.dist-info/WHEEL +5 -0
- shield_kya-0.17.0.dist-info/top_level.txt +1 -0
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}")
|