shield-kya 0.17.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shield_kya-0.17.0/PKG-INFO +265 -0
- shield_kya-0.17.0/README.md +232 -0
- shield_kya-0.17.0/pyproject.toml +43 -0
- shield_kya-0.17.0/setup.cfg +4 -0
- shield_kya-0.17.0/shield_kya/__init__.py +383 -0
- shield_kya-0.17.0/shield_kya/_never_list.py +94 -0
- shield_kya-0.17.0/shield_kya/integrations/__init__.py +109 -0
- shield_kya-0.17.0/shield_kya/integrations/adk.py +92 -0
- shield_kya-0.17.0/shield_kya/integrations/agent_framework.py +91 -0
- shield_kya-0.17.0/shield_kya/integrations/crewai.py +213 -0
- shield_kya-0.17.0/shield_kya/integrations/langchain.py +78 -0
- shield_kya-0.17.0/shield_kya/integrations/langgraph.py +65 -0
- shield_kya-0.17.0/shield_kya/integrations/openai_agents.py +173 -0
- shield_kya-0.17.0/shield_kya/integrations/pydantic_ai.py +129 -0
- shield_kya-0.17.0/shield_kya.egg-info/PKG-INFO +265 -0
- shield_kya-0.17.0/shield_kya.egg-info/SOURCES.txt +32 -0
- shield_kya-0.17.0/shield_kya.egg-info/dependency_links.txt +1 -0
- shield_kya-0.17.0/shield_kya.egg-info/requires.txt +21 -0
- shield_kya-0.17.0/shield_kya.egg-info/top_level.txt +1 -0
- shield_kya-0.17.0/tests/test_e2e_adk.py +153 -0
- shield_kya-0.17.0/tests/test_e2e_agent_framework.py +147 -0
- shield_kya-0.17.0/tests/test_e2e_crewai.py +200 -0
- shield_kya-0.17.0/tests/test_e2e_langchain.py +195 -0
- shield_kya-0.17.0/tests/test_e2e_openai_agents.py +145 -0
- shield_kya-0.17.0/tests/test_e2e_pydantic_ai.py +117 -0
- shield_kya-0.17.0/tests/test_integrations_adk.py +359 -0
- shield_kya-0.17.0/tests/test_integrations_agent_framework.py +326 -0
- shield_kya-0.17.0/tests/test_integrations_crewai.py +360 -0
- shield_kya-0.17.0/tests/test_integrations_langchain.py +104 -0
- shield_kya-0.17.0/tests/test_integrations_langgraph.py +143 -0
- shield_kya-0.17.0/tests/test_integrations_openai_agents.py +411 -0
- shield_kya-0.17.0/tests/test_integrations_pydantic_ai.py +256 -0
- shield_kya-0.17.0/tests/test_never_list.py +51 -0
- shield_kya-0.17.0/tests/test_shield_kya.py +578 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shield-kya
|
|
3
|
+
Version: 0.17.0
|
|
4
|
+
Summary: Audit trail and traceability for AI agents. In-process kya shim for Python agent frameworks (offline evaluate via the kya CLI).
|
|
5
|
+
Author: MFR Limited
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/The-Pixel-Boys/shield-kya
|
|
8
|
+
Project-URL: Repository, https://github.com/The-Pixel-Boys/shield-kya
|
|
9
|
+
Keywords: kya,know-your-agent,governance,mcp,agents
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Provides-Extra: langchain
|
|
20
|
+
Requires-Dist: langchain<2,>=1.4.4; extra == "langchain"
|
|
21
|
+
Provides-Extra: langgraph
|
|
22
|
+
Requires-Dist: langgraph; extra == "langgraph"
|
|
23
|
+
Provides-Extra: crewai
|
|
24
|
+
Requires-Dist: crewai<2,>=1.15.27; extra == "crewai"
|
|
25
|
+
Provides-Extra: pydantic-ai
|
|
26
|
+
Requires-Dist: pydantic-ai-slim<3,>=2.55.0; extra == "pydantic-ai"
|
|
27
|
+
Provides-Extra: adk
|
|
28
|
+
Requires-Dist: google-adk<3,>=2.11.0; extra == "adk"
|
|
29
|
+
Provides-Extra: agent-framework
|
|
30
|
+
Requires-Dist: agent-framework-core<2,>=1.21.0; extra == "agent-framework"
|
|
31
|
+
Provides-Extra: openai-agents
|
|
32
|
+
Requires-Dist: openai-agents<0.24,>=0.23.1; extra == "openai-agents"
|
|
33
|
+
|
|
34
|
+
# shield-kya
|
|
35
|
+
|
|
36
|
+
In-process [kya](https://github.com/The-Pixel-Boys/shield-kya) (Know Your Agent) governance shim for Python agent frameworks.
|
|
37
|
+
|
|
38
|
+
`shield_kya` delegates policy evaluation to the local kya CLI (`kya wrap --offline --json ...`), so Python tools share the same offline verdict path and trail as the TypeScript SDK. Stdlib only, no dependencies.
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
- Python >= 3.9 (the framework hooks need what their framework needs: langchain 3.10+, pydantic-ai 3.11+, crewai 3.10 to 3.13, google-adk 3.10+, agent-framework-core 3.10+, openai-agents 3.10+)
|
|
43
|
+
- The kya CLI on `PATH` (`npm i -g @shield-agent/kya`), or set `KYA_CLI` to an absolute path (an executable, or a `.js` file run with `node`). `kya` is looked up in absolute PATH entries only, never the current directory; a relative `KYA_CLI` is ignored.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
Not yet published to PyPI: do not `pip install shield-kya` from PyPI until it is. Install from a checkout, in this directory:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
pip install .
|
|
51
|
+
pip install '.[langchain]' # LangChain / LangGraph middleware
|
|
52
|
+
pip install '.[pydantic-ai]' # Pydantic AI capability
|
|
53
|
+
pip install '.[crewai]' # CrewAI global hook
|
|
54
|
+
pip install '.[adk]' # Google ADK plugin
|
|
55
|
+
pip install '.[agent-framework]' # Microsoft Agent Framework middleware (agent-framework-core)
|
|
56
|
+
pip install '.[openai-agents]' # OpenAI Agents SDK tool input guardrail
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Semantics: advisory, fail-open
|
|
60
|
+
|
|
61
|
+
Same rules as `kya hook`:
|
|
62
|
+
|
|
63
|
+
| Situation | Default (OSS) | `fail_closed=True` / `KYA_FAIL_CLOSED=1` (hosted) |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `ALLOW` | tool runs | tool runs |
|
|
66
|
+
| `REQUIRE_APPROVE` | tool runs, recorded (`strict=True` blocks) | same |
|
|
67
|
+
| `DENY` (local never-list) | blocked | blocked |
|
|
68
|
+
| CLI missing, timeout (4 s), non-JSON, unknown exit code, empty verdict, any shim error | tool runs; first event logged at WARNING, every event at DEBUG (logger `shield_kya`) | blocked |
|
|
69
|
+
| same, but the tool name is on the never-list | blocked (`NEVER_EVENT`) | blocked |
|
|
70
|
+
|
|
71
|
+
Without a CLI verdict the never-list is matched in Python (`shield_kya/_never_list.py`, a port of the CLI's name-only rule; `tests/test_never_list.py` and the TS suite assert the same vectors file). `KYA_FAIL_CLOSED` accepts 1/true/yes/on (any case). Hosted operators should not pass `fail_closed=False`, which overrides the env var.
|
|
72
|
+
|
|
73
|
+
On Windows the shim refuses to run `kya` through a `.cmd`/`.bat` shim (cmd.exe would re-parse the model-controlled arguments); set `KYA_CLI` to the absolute path of the package's `dist/cli.js` and it runs with `node`.
|
|
74
|
+
|
|
75
|
+
Arguments are passed to the CLI on argv. A tool call whose arguments exceed the OS argv limit (about 128 KiB per argument on Linux) gets no CLI verdict and no trail row: it fails open, and the never-list still applies by tool name. An empty tool id is denied (`EMPTY_TOOL`), like the CLI's offline evaluate.
|
|
76
|
+
|
|
77
|
+
"Hosted" here means only the fail-closed switch: the shim always evaluates offline (`kya wrap --offline`) and never consults a hosted policy plane.
|
|
78
|
+
|
|
79
|
+
Async callers use an asyncio subprocess, so the check never blocks the event loop.
|
|
80
|
+
|
|
81
|
+
## LangChain / LangGraph (agent-wide)
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from langchain.agents import create_agent
|
|
85
|
+
from shield_kya.integrations.langchain import KyaMiddleware
|
|
86
|
+
|
|
87
|
+
agent = create_agent(model, tools, middleware=[KyaMiddleware()])
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Each tool call this agent dispatches is checked before it runs (one trail row per call that the CLI evaluated). A `DENY` skips the tool and returns an error `ToolMessage` with the reason (`KYA denied postgres__drop_table: NEVER_EVENT`), so the model sees it and the run continues. It covers tool calls dispatched by that agent's ToolNode. Not covered: subagents or graphs built separately (add the middleware there too), direct `tool.invoke()` calls, tools the model provider executes server-side, and MCP tools whose names carry no server prefix, so the never-list cannot recognize them (`server=` applies one prefix to every tool of the agent). Put `KyaMiddleware` last in `middleware` (innermost) so it checks the call as it will execute, after any middleware that rewrites tool calls. Options: `server=` (tool id becomes `server__tool`), `irreversible=`, `strict=` (raise `KyaDenied`, and block `REQUIRE_APPROVE`), `fail_closed=`, `timeout=`.
|
|
91
|
+
|
|
92
|
+
Raw LangGraph graph:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
kya = KyaMiddleware()
|
|
96
|
+
ToolNode(tools, wrap_tool_call=kya.wrap_tool_call, awrap_tool_call=kya.awrap_tool_call)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Pydantic AI (agent-wide)
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from pydantic_ai import Agent
|
|
103
|
+
from shield_kya.integrations.pydantic_ai import KyaCapability
|
|
104
|
+
|
|
105
|
+
agent = Agent(model, tools=[...], capabilities=[KyaCapability()])
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The capability hooks `before_tool_execute`, so every function tool the agent runs (toolsets and MCP servers included, but see Known limits on MCP names) is checked before it executes, with the CLI run as an asyncio subprocess (`run_sync` included, since Pydantic AI runs tools on its event loop). A `DENY` raises `SkipToolExecution` with the reason, so the model receives `KYA denied postgres__drop_table: NEVER_EVENT` as the tool result and the run continues. `strict=True` raises `KyaDenied` instead, which aborts the run, and also blocks `REQUIRE_APPROVE`. Output tools skip capability hooks and are not checked. The capability is per agent: add `KyaCapability()` to every agent, including agents another agent runs as a tool (subagents). Put `KyaCapability` last in `capabilities` so the trail records the args as they will execute. Options: `server=`, `irreversible=`, `strict=`, `fail_closed=`, `timeout=`.
|
|
109
|
+
|
|
110
|
+
## CrewAI (process-wide)
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from shield_kya.integrations import crewai as kya_crewai
|
|
114
|
+
|
|
115
|
+
kya_crewai.register() # every tool call of every crew in this process
|
|
116
|
+
crew.kickoff()
|
|
117
|
+
kya_crewai.unregister() # optional
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`register()` adds a global `PRE_TOOL_CALL` hook and a matching `POST_TOOL_CALL` hook; calling it again replaces them. CrewAI swallows every hook exception except `HookAborted` and then runs the tool, so a deny is always a `HookAborted`: the tool does not run, and the post hook replaces CrewAI's fixed "Tool execution blocked by hook" text with the KYA reason, so the agent sees it and the crew continues. For the same reason `strict=True` blocks `REQUIRE_APPROVE` but never raises `KyaDenied`, and an unexpected error inside the hook still goes through the fail-open / never-list / fail-closed rules instead of reaching CrewAI. The tool id is the tool's own name: CrewAI's LLM-facing name is sanitized (`postgres__drop_table` becomes `postgres_drop_table`), which the never-list would not recognize. Call `register()` after your own pre-tool hooks so the check sees their input edits. Options: `server=`, `irreversible=`, `strict=`, `fail_closed=`, `timeout=`.
|
|
121
|
+
|
|
122
|
+
## Google ADK (Runner-wide)
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from google.adk.apps import App
|
|
126
|
+
from shield_kya.integrations.adk import KyaPlugin
|
|
127
|
+
|
|
128
|
+
app = App(name="my_app", root_agent=agent, plugins=[KyaPlugin()])
|
|
129
|
+
runner = InMemoryRunner(app=app) # or Runner(app=app, ...)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`KyaPlugin` is a `BasePlugin` whose `before_tool_callback` checks every tool call the Runner dispatches, with the CLI run as an asyncio subprocess (`Runner.run` included, since ADK runs tools on its event loop). A `DENY` returns `{"error": "KYA denied postgres__drop_table: NEVER_EVENT"}`, which ADK uses as the tool result without running the tool, so the model reads the reason and the run continues. ADK re-raises any exception from a plugin as a `RuntimeError` that aborts the run, so the plugin never lets one escape: a shim error goes through the fail-open / never-list / fail-closed rules. `strict=True` raises `KyaDenied` on purpose (ADK wraps it in that `RuntimeError`) and also blocks `REQUIRE_APPROVE`.
|
|
133
|
+
|
|
134
|
+
Covered (one plugin on the App): every agent the Runner runs, including `sub_agents` reached by `transfer_to_agent`, an `AgentTool` with its default `include_plugins=True`, MCP toolsets and long-running tools (the first call is checked), all tested; live (bidi) mode and workflow tool nodes call the same plugin hook in the google-adk 2.11.0 source (not tested here). ADK's own `transfer_to_agent` and `AgentTool` calls are checked too (UNKNOWN_TOOL, advisory; `strict=True` blocks them). Not covered: `AgentTool(include_plugins=False)`, a separate `Runner` or `App` without the plugin, tools the model provider runs server-side (`google_search`, built-in code execution), an agent `code_executor` running model-written code (not a tool call), and direct `tool.run_async()` calls. Plugins run in list order and an agent's own `before_tool_callback` runs after them, so put `KyaPlugin` last in `plugins` and do not rewrite tool args in agent callbacks. Options: `server=`, `irreversible=`, `strict=`, `fail_closed=`, `timeout=`, `name=` (plugin name, default `shield_kya`).
|
|
135
|
+
|
|
136
|
+
## Microsoft Agent Framework (agent-wide)
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from agent_framework import Agent
|
|
140
|
+
from shield_kya.integrations.agent_framework import KyaMiddleware
|
|
141
|
+
|
|
142
|
+
agent = Agent(client=client, tools=[...], middleware=[KyaMiddleware()])
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`KyaMiddleware` is a `FunctionMiddleware` (package `agent-framework-core`, the AutoGen successor). Before a function runs it is checked with the CLI as an asyncio subprocess. A `DENY` sets `context.result` to the reason and does not call `call_next()`, so the function never runs, the model receives `KYA denied postgres__drop_table: NEVER_EVENT` as the function result and the run continues. Agent Framework turns an ordinary exception from middleware into a tool-error result (the tool does not run), so the middleware never raises one: a shim error goes through the fail-open / never-list / fail-closed rules instead of silently blocking a harmless tool, and a tool's own exception stays the tool's. `strict=True` raises `MiddlewareFailure` (from `KyaDenied`), Agent Framework's fail-closed signal that aborts the run, and also blocks `REQUIRE_APPROVE`. Hosted `fail_closed=True` / `KYA_FAIL_CLOSED=1` blocks the tool the same way as a deny and keeps the run going.
|
|
146
|
+
|
|
147
|
+
Covered: every function call of that agent's function-invocation loop (`run` and `run(stream=True)`), and MCP functions (tested); approved replays of `approval_mode="always_require"` tools go through the same middleware in the agent-framework-core 1.21.0 source (not tested here). Middleware on the chat client (`client = SomeClient(middleware=[KyaMiddleware()])`) covers every agent that uses the client. Not covered: an agent wrapped with `as_tool()` unless that agent (or its client) has its own `KyaMiddleware` (the `as_tool` call itself is checked), hosted tools the service runs (code interpreter, web search, hosted MCP), and direct `FunctionTool.invoke()` calls. The first middleware in the list is the outermost, so put `KyaMiddleware` last: argument-repair middleware must run before it (Agent Framework's own rule for security middleware). Options: `server=`, `irreversible=`, `strict=`, `fail_closed=`, `timeout=`.
|
|
148
|
+
|
|
149
|
+
## OpenAI Agents SDK (every function tool present at attach() time, except SandboxAgent tools added at run time)
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from agents import Agent, Runner
|
|
153
|
+
from shield_kya.integrations.openai_agents import attach
|
|
154
|
+
|
|
155
|
+
agent = attach(Agent(name="a", tools=[...], mcp_servers=[...], handoffs=[...]))
|
|
156
|
+
Runner.run_sync(agent, "...")
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The SDK has no agent-wide pre-tool hook that can skip a tool (`RunHooks.on_tool_start` can only abort the whole run), only per-tool `tool_input_guardrails`, so `attach()` puts one KYA guardrail (named `shield_kya`) on every `FunctionTool` in `agent.tools` and on every MCP server in `agent.mcp_servers`, and walks `handoffs` (an `Agent` or a `handoff(agent)`) and `agent.as_tool()` agents recursively. The SDK copies a server's guardrails onto every MCP tool it lists, so MCP tools a server adds later are covered without another call.
|
|
160
|
+
|
|
161
|
+
**Not covered.** Shell and file-editing tools: `ShellTool`, `LocalShellTool`, `ApplyPatchTool`, `ComputerTool`, `CustomTool`, and the shell, filesystem, apply-patch and image tools a `SandboxAgent` adds at run time (sandbox capabilities, created after `attach()`). An agent with them runs commands and edits files without a KYA check; for coding agents the shell and apply-patch tools are the dangerous ones, and this integration does not gate them. Also not covered: hosted tools the OpenAI service runs (`WebSearchTool`, `FileSearchTool`, `CodeInterpreterTool`, `ImageGenerationTool`, `HostedMCPTool`, `ToolSearchTool`), the handoff call itself (only the target agent's tools are checked), the commands Codex runs inside the experimental `codex_tool()` (the `codex` call itself is checked), function tools added to `agent.tools` after `attach()` (call it again), agents reached only through a custom `Handoff` without an agent reference, and direct `on_invoke_tool` calls. `ProgrammaticToolCallingTool` was not checked.
|
|
162
|
+
|
|
163
|
+
`attach()` returns the agent and is idempotent: a second call replaces the KYA guardrail and keeps your own guardrails. For one tool, `kya_tool_input_guardrail()` returns the guardrail to put in `tool_input_guardrails=[...]` yourself; for an MCP server use `attach([server])` or pass `mcp_server_name=server.name`, because the guardrail cannot read the server from the call (without it `drop_table` is checked alone and the never-list cannot tell it is `postgres__drop_table`).
|
|
164
|
+
|
|
165
|
+
Before a tool runs, the guardrail checks the call with the CLI as an asyncio subprocess (`Runner.run_sync` included, since the SDK runs guardrails on its event loop). A `DENY` returns `ToolGuardrailFunctionOutput.reject_content(reason)`: the tool does not run, the model receives `KYA denied postgres__drop_table: NEVER_EVENT` as the tool output and the run continues. The SDK aborts the run on any exception from a guardrail, so the guardrail never raises one: a shim error goes through the fail-open / never-list / fail-closed rules. `strict=True` returns `raise_exception` instead (the SDK raises `ToolInputGuardrailTripwireTriggered` and the run aborts) and also blocks `REQUIRE_APPROVE`. Hosted `fail_closed=True` / `KYA_FAIL_CLOSED=1` blocks the tool the same way as a deny and keeps the run going.
|
|
166
|
+
|
|
167
|
+
The tool id is the name the model called (`ToolContext.tool_name`), with the arguments the tool runs with. The never-list is also matched against `<namespace>.<name>` for a `tool_namespace()` tool and, for an MCP tool, against `<server name>__<tool name>`: an MCP server created with `name="postgres"` exposes `drop_table` (or `mcp_postgres__drop_table` with `mcp_config={"include_server_in_tool_names": True}`), and the check matches it as `postgres__drop_table` (the deny reason names both). Covered (tested): function tools of the agent, of handoff targets and of `as_tool()` agents, MCP server tools (both naming modes), namespaced tools, `Runner.run`, `Runner.run_sync` and `Runner.run_streamed`. Realtime agents run the same tool input guardrails in the openai-agents 0.23.1 source (not tested here). With `RunConfig(tool_execution=...)` `pre_approval_tool_input_guardrails=True` the guardrail runs before approval and again at execution, so an approved call has two trail rows. Options: `server=` (function tools only: an MCP tool already carries its server name), `irreversible=`, `strict=`, `fail_closed=`, `timeout=`.
|
|
168
|
+
|
|
169
|
+
## Tested versions
|
|
170
|
+
|
|
171
|
+
| Framework | Entry point | Tested with |
|
|
172
|
+
|---|---|---|
|
|
173
|
+
| LangChain / LangGraph | `KyaMiddleware` | langchain 1.4.4 (langchain-core 1.6.9, langgraph 1.2.14, langgraph-prebuilt 1.1.0) |
|
|
174
|
+
| Pydantic AI | `KyaCapability` | pydantic-ai-slim 2.55.0 (Python 3.12, 3.13) |
|
|
175
|
+
| CrewAI | `register()` | crewai 1.15.27 (Python 3.12, 3.13; crewai needs Python < 3.14) |
|
|
176
|
+
| Google ADK | `KyaPlugin` | google-adk 2.11.0 (Python 3.12) |
|
|
177
|
+
| Microsoft Agent Framework | `KyaMiddleware` | agent-framework-core 1.21.0 (Python 3.12) |
|
|
178
|
+
| OpenAI Agents SDK | `attach()` | openai-agents 0.23.1 (Python 3.12) |
|
|
179
|
+
|
|
180
|
+
CI runs the unit tests of all six at the versions above, and the real-CLI e2e (`tests/test_e2e_*.py`) for Pydantic AI, CrewAI, Google ADK, Agent Framework and the OpenAI Agents SDK; the LangChain e2e is still a manual gate.
|
|
181
|
+
|
|
182
|
+
## Known limits
|
|
183
|
+
|
|
184
|
+
- `KYA_CLI` set to an absolute path is trusted as is: the shim runs it. Its verdict cannot ALLOW a never-list tool (the never-list is matched in Python on every call), but the program itself still runs.
|
|
185
|
+
- crewai calls `load_dotenv()` at import. From `python -c`, a notebook or a debugger it reads the current directory's `.env`, otherwise the first `.env` above crewai's install directory (a `.venv` inside a repo reaches the repo's `.env`). A cloned repo can therefore set `KYA_CLI` for a CrewAI process (the never-list still blocks, see above). `load_dotenv()` does not replace variables that are already set, so exporting `KYA_CLI` yourself keeps a repo `.env` from choosing it. python-dotenv also expands `${PWD}` and other variables, so a `.env` can name absolute paths inside the repo. The child's `PATH` and `HOME` are only safe because crewai calls `load_dotenv()` without `override=True`, so an exported value wins; a `.env` that sets `HOME` or `PATH` for a process where they are unset is a risk the shim does not defend against.
|
|
186
|
+
- An empty tool id is denied (`EMPTY_TOOL`), like the CLI's offline evaluate; `kya hook` allows a hook payload that has no tool name.
|
|
187
|
+
- A grandchild that leaves the CLI's process group and keeps its output open can stretch a timeout past the 4 s budget. The real CLI's detached helpers do not hold its output.
|
|
188
|
+
- The child's PATH keeps only absolute entries, so a `#!/usr/bin/env node` launcher cannot pick up a repo-planted `./node`.
|
|
189
|
+
- CrewAI hooks are synchronous. Under `crew.akickoff()` the check blocks the event loop for up to `timeout` (4 s by default) per tool call; there is no async hook to use instead.
|
|
190
|
+
- CrewAI's `register()` is process-wide: it covers every crew and agent in the process, not one crew.
|
|
191
|
+
- The CrewAI reason hand-off from the pre hook to the post hook is per thread. If another pre-tool hook blocks a call before ours runs, CrewAI's own text stays (the tool is still blocked).
|
|
192
|
+
- Google ADK: `adk run`, `adk web` and `adk api_server` load the agent folder's `.env` with `load_dotenv(override=True)`, so under those commands a repo `.env` can replace `PATH`, `HOME`, `KYA_CLI` and `KYA_FAIL_CLOSED` (the allowlist above does not help: these are the values it forwards or reads). The never-list still blocks by name, but a planted `KYA_CLI` or `PATH` entry runs. In the google-adk 2.11.0 source only those CLI commands load a `.env`; a program that builds its own `Runner` does not (other libraries it imports may). agent-framework-core reads a `.env` only when you pass `env_file_path` (settings read it without changing `os.environ`; the observability setup loads it without `override`).
|
|
193
|
+
- The never-list only recognizes MCP tools named `server__tool` (or `server.tool`, `server/tool`, `mcp__server__tool`) with a known server. Google ADK and Agent Framework keep the original MCP name, so their plugin and middleware also check that name and `<prefix>__<original>`: an ADK toolset or Agent Framework `MCPTool` with `tool_name_prefix="postgres"` exposes `postgres_drop_table`, and the check matches it as `postgres__drop_table` (the deny reason names both). Without a prefix, an MCP tool is just `drop_table` in both frameworks and the never-list cannot tell which server it belongs to (`server=` applies one prefix to every tool). Agent Framework replaces characters outside `A-Za-z0-9_.-` with `-` in the exposed name; the raw remote name is checked as well. A prefix that names a known server can also block a harmless tool whose name contains a never-list verb (`github_transfer_issue` is checked as `github__transfer_issue`), the same over-block the never-list has for `server__tool` names. The other frameworks' own MCP names do not match: Pydantic AI `load_mcp_servers()` / `toolset.prefixed("postgres")` gives `postgres_drop_table` (one `_`), and CrewAI `mcps=` names tools `<server_name>_<tool>`, where a stdio `server_name` is the command line and an HTTP one the host, so CrewAI MCP tools are never matched. Those calls are still evaluated and recorded, but run (UNKNOWN_TOOL is advisory). What to do: name MCP tools in the `server__tool` form (Pydantic AI `toolset.prefixed("postgres_")` gives `postgres__drop_table`; LangChain `server=`), or rely on hosted enforcement.
|
|
194
|
+
- CrewAI MCP tool ids are the original tool names, which for stdio servers embed the full command line, including any DSN password (`postgresql://user:pass@...`). That id goes on the kya argv (visible in `ps`), into `~/.kya/trail.jsonl` and into the deny reason. Keep secrets out of MCP command lines (use the server's env instead) when you use `register()`.
|
|
195
|
+
- Pydantic AI output tools are not checked.
|
|
196
|
+
- Agent Framework: `KyaMiddleware` on the chat client always runs outside the agent's own function middleware (agent-framework-core 1.21.0 puts client middleware first), so agent-level middleware can rewrite the arguments after the check. With `KyaMiddleware` on the client, put argument-rewriting function middleware on the client too, before `KyaMiddleware`, not on the agent.
|
|
197
|
+
- `server=` on the agent-wide Google ADK, Agent Framework and OpenAI Agents SDK hooks prefixes the tool id sent to the CLI, but the never-list also matches every name without that prefix, so a prefix it does not know (`server="acme"`) cannot hide `postgres__drop_table`; the denied call is recorded under the unprefixed id.
|
|
198
|
+
- Google ADK: the original MCP name is read from `McpTool.raw_mcp_tool` (public in google-adk 2.11.0), else the private `_mcp_tool`. The tests use stand-in objects with one or the other attribute, not a real `McpTool`.
|
|
199
|
+
- Google ADK: `ManagedAgent` tools and `RemoteMcpServer` tools run on Google's backend (ADK never connects to that MCP server), so they never reach `before_tool_callback` and are not checked.
|
|
200
|
+
- OpenAI Agents SDK: the guardrail is per tool, so a function tool added to `agent.tools` after `attach()` is not checked until `attach()` runs again (MCP tools are, through the server). An MCP tool name the SDK shortened with a hash (server plus tool name over 64 characters) is not matched by its `<server>__<tool>` form. An MCP server name or a `tool_namespace()` name that is a known server can also block a harmless tool whose name contains a never-list verb (server `github` with `transfer_issue` is checked as `github__transfer_issue`), the same over-block as above. Options live on each tool and server object: when two agents share one, the last `attach()` wins for both (`attach(a, fail_closed=True)` then `attach(b)` makes a shared tool fail open for `a` too). Shell and file-editing tools (`ShellTool`, `LocalShellTool`, `ApplyPatchTool`, `ComputerTool`, `CustomTool`, `SandboxAgent` run-time tools) are not checked; see Not covered in the OpenAI Agents SDK section.
|
|
201
|
+
- The CLI child gets an allowlist of the environment, nothing else: an absolute-only `PATH`, `HOME`, `USERPROFILE`, `LANG`, `LC_*`, `TZ`, on Windows also `SYSTEMROOT`, `WINDIR`, `COMSPEC`, `PATHEXT`, `APPDATA`, `LOCALAPPDATA`, `TEMP` and `TMP`, plus `KYA_SKIP_NODE_CHECK=1` set by the shim (`TMPDIR` is left out). A framework can load a repo `.env` into `os.environ` (crewai does at import), and other variables would run repo code in node (`NODE_OPTIONS=--require ./x.js`, `OPENSSL_CONF` loading a provider library, `LD_PRELOAD`), move the trail (`KYA_HOME`) or send trail events to a webhook (`KYA_NOTIFY_WEBHOOK`, `KYA_OTLP_EXPORT_ENDPOINT`). A user-exported value looks the same, so CLI settings such as `KYA_HOME`, `KYA_SESSION_ID`, `KYA_TELEMETRY`, `KYA_NOTIFY_WEBHOOK`, proxy and CA variables are not supported through the shim: the trail always goes to `$HOME/.kya`. The privacy opt-outs `KYA_DIFF_PREVIEW` and `KYA_SEND_PREVIEWS` are forwarded only when set to an off value (0, false, no, off), so a planted value can only remove data. `KYA_CLI` and `KYA_FAIL_CLOSED` are read by the shim itself and still work.
|
|
202
|
+
- A committed repo `.kya/config.json` still applies: the CLI runs in the agent's working directory and reads that file, so a repo can add notify webhooks or an OTLP endpoint that receive trail events (every DENY and REQUIRE_APPROVE, with tool ids and summaries). This is CLI behaviour, the same for `kya hook`, not something the shim's environment allowlist can stop. CLI follow-up: do not honor repo-level webhook config in hook mode.
|
|
203
|
+
- CrewAI: when two tools sanitize to the same name (`postgres__drop_table` and `postgres_drop_table`), CrewAI can hand the hook one and run the other, so the hook denies the call if any tool with that sanitized name on the agent, the task, the agent's executor (`mcps=`, `apps=`, `execute_task(tools=)`) or a LiteAgent is on the never-list. The harmless twin is then blocked too, with a reason that names both: `KYA denied postgres_drop_table: a tool with the same sanitized name (postgres__drop_table) is on the never-list (NEVER_EVENT)`; the trail row is the DENY for `postgres__drop_table`.
|
|
204
|
+
- CrewAI `strict=True` also blocks CrewAI's own delegation tools (`delegate_work_to_coworker`, `ask_question_to_coworker`), which evaluate as REQUIRE_APPROVE UNKNOWN_TOOL.
|
|
205
|
+
- The legacy per-tool wrappers (`governed_tool`, `wrap_tool`, `wrap_tools`) still raise `KyaDenied` on a deny, which aborts a Pydantic AI run; prefer the agent-wide entry points.
|
|
206
|
+
|
|
207
|
+
## Core API
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
import shield_kya
|
|
211
|
+
|
|
212
|
+
d = shield_kya.decide("web_search", {"q": "kya"}) # never raises; records the trail
|
|
213
|
+
d.allowed, d.verdict, d.reason_code, d.reason
|
|
214
|
+
d = await shield_kya.adecide("web_search", {"q": "kya"}) # async, off the event loop
|
|
215
|
+
|
|
216
|
+
shield_kya.evaluate("org.sample.data.write", args={"table": "users"}) # raw eval-tool dict; raises on CLI errors
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`@governed` gates a sync or async callable. A plain function has no other way to report a deny, so `DENY` raises `KyaDenied` before the function runs:
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
from shield_kya import governed, KyaDenied
|
|
223
|
+
|
|
224
|
+
@governed("org.sample.data.write", irreversible=True)
|
|
225
|
+
def write_users_table(rows): ...
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
write_users_table(rows)
|
|
229
|
+
except KyaDenied as denied:
|
|
230
|
+
print(denied.verdict, denied.reason_code)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Per-tool wrappers
|
|
234
|
+
|
|
235
|
+
Prefer the agent-wide entry points above. These wrap single tools and share the `governed` semantics (`DENY` raises `KyaDenied`). Framework imports are lazy.
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
from shield_kya.integrations.langgraph import governed_tool # LangGraph / LangChain @tool
|
|
239
|
+
tools = [governed_tool("web.fetch")(fetch_fn)]
|
|
240
|
+
|
|
241
|
+
from shield_kya.integrations.crewai import wrap_tool # CrewAI BaseTool or callable
|
|
242
|
+
safe_search = wrap_tool(search_tool, tool_id="search.web")
|
|
243
|
+
|
|
244
|
+
from shield_kya.integrations.pydantic_ai import governed_tool # Pydantic AI
|
|
245
|
+
|
|
246
|
+
@agent.tool_plain
|
|
247
|
+
@governed_tool("search.web")
|
|
248
|
+
def search_web(q: str) -> str: ...
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
A raised `KyaDenied` aborts a Pydantic AI run (Pydantic AI does not turn tool exceptions into tool errors).
|
|
252
|
+
|
|
253
|
+
Extras (`pip install '.[name]'` from a checkout): `langchain`, `langgraph`, `crewai` (>=1.15.27,<2), `pydantic-ai` (pydantic-ai-slim >=2.55.0,<3), `adk` (google-adk >=2.11.0,<3), `agent-framework` (agent-framework-core >=1.21.0,<2), `openai-agents` (openai-agents >=0.23.1,<0.24).
|
|
254
|
+
|
|
255
|
+
## Tests
|
|
256
|
+
|
|
257
|
+
```sh
|
|
258
|
+
python -m unittest discover -s tests # stdlib suite; framework tests run when the framework is installed
|
|
259
|
+
KYA_E2E_BIN=/dir/with/kya python -m unittest discover -s tests -p 'test_e2e_*.py' # real-CLI e2e
|
|
260
|
+
KYA_E2E_BIN=/dir/with/kya python -m unittest discover -s tests -p 'test_shield_kya.py' -k RealCliEnvTest # real CLI: a planted OPENSSL_CONF never reaches node (POSIX, needs cc for the full check)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## License
|
|
264
|
+
|
|
265
|
+
MIT
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# shield-kya
|
|
2
|
+
|
|
3
|
+
In-process [kya](https://github.com/The-Pixel-Boys/shield-kya) (Know Your Agent) governance shim for Python agent frameworks.
|
|
4
|
+
|
|
5
|
+
`shield_kya` delegates policy evaluation to the local kya CLI (`kya wrap --offline --json ...`), so Python tools share the same offline verdict path and trail as the TypeScript SDK. Stdlib only, no dependencies.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Python >= 3.9 (the framework hooks need what their framework needs: langchain 3.10+, pydantic-ai 3.11+, crewai 3.10 to 3.13, google-adk 3.10+, agent-framework-core 3.10+, openai-agents 3.10+)
|
|
10
|
+
- The kya CLI on `PATH` (`npm i -g @shield-agent/kya`), or set `KYA_CLI` to an absolute path (an executable, or a `.js` file run with `node`). `kya` is looked up in absolute PATH entries only, never the current directory; a relative `KYA_CLI` is ignored.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Not yet published to PyPI: do not `pip install shield-kya` from PyPI until it is. Install from a checkout, in this directory:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pip install .
|
|
18
|
+
pip install '.[langchain]' # LangChain / LangGraph middleware
|
|
19
|
+
pip install '.[pydantic-ai]' # Pydantic AI capability
|
|
20
|
+
pip install '.[crewai]' # CrewAI global hook
|
|
21
|
+
pip install '.[adk]' # Google ADK plugin
|
|
22
|
+
pip install '.[agent-framework]' # Microsoft Agent Framework middleware (agent-framework-core)
|
|
23
|
+
pip install '.[openai-agents]' # OpenAI Agents SDK tool input guardrail
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Semantics: advisory, fail-open
|
|
27
|
+
|
|
28
|
+
Same rules as `kya hook`:
|
|
29
|
+
|
|
30
|
+
| Situation | Default (OSS) | `fail_closed=True` / `KYA_FAIL_CLOSED=1` (hosted) |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `ALLOW` | tool runs | tool runs |
|
|
33
|
+
| `REQUIRE_APPROVE` | tool runs, recorded (`strict=True` blocks) | same |
|
|
34
|
+
| `DENY` (local never-list) | blocked | blocked |
|
|
35
|
+
| CLI missing, timeout (4 s), non-JSON, unknown exit code, empty verdict, any shim error | tool runs; first event logged at WARNING, every event at DEBUG (logger `shield_kya`) | blocked |
|
|
36
|
+
| same, but the tool name is on the never-list | blocked (`NEVER_EVENT`) | blocked |
|
|
37
|
+
|
|
38
|
+
Without a CLI verdict the never-list is matched in Python (`shield_kya/_never_list.py`, a port of the CLI's name-only rule; `tests/test_never_list.py` and the TS suite assert the same vectors file). `KYA_FAIL_CLOSED` accepts 1/true/yes/on (any case). Hosted operators should not pass `fail_closed=False`, which overrides the env var.
|
|
39
|
+
|
|
40
|
+
On Windows the shim refuses to run `kya` through a `.cmd`/`.bat` shim (cmd.exe would re-parse the model-controlled arguments); set `KYA_CLI` to the absolute path of the package's `dist/cli.js` and it runs with `node`.
|
|
41
|
+
|
|
42
|
+
Arguments are passed to the CLI on argv. A tool call whose arguments exceed the OS argv limit (about 128 KiB per argument on Linux) gets no CLI verdict and no trail row: it fails open, and the never-list still applies by tool name. An empty tool id is denied (`EMPTY_TOOL`), like the CLI's offline evaluate.
|
|
43
|
+
|
|
44
|
+
"Hosted" here means only the fail-closed switch: the shim always evaluates offline (`kya wrap --offline`) and never consults a hosted policy plane.
|
|
45
|
+
|
|
46
|
+
Async callers use an asyncio subprocess, so the check never blocks the event loop.
|
|
47
|
+
|
|
48
|
+
## LangChain / LangGraph (agent-wide)
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from langchain.agents import create_agent
|
|
52
|
+
from shield_kya.integrations.langchain import KyaMiddleware
|
|
53
|
+
|
|
54
|
+
agent = create_agent(model, tools, middleware=[KyaMiddleware()])
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Each tool call this agent dispatches is checked before it runs (one trail row per call that the CLI evaluated). A `DENY` skips the tool and returns an error `ToolMessage` with the reason (`KYA denied postgres__drop_table: NEVER_EVENT`), so the model sees it and the run continues. It covers tool calls dispatched by that agent's ToolNode. Not covered: subagents or graphs built separately (add the middleware there too), direct `tool.invoke()` calls, tools the model provider executes server-side, and MCP tools whose names carry no server prefix, so the never-list cannot recognize them (`server=` applies one prefix to every tool of the agent). Put `KyaMiddleware` last in `middleware` (innermost) so it checks the call as it will execute, after any middleware that rewrites tool calls. Options: `server=` (tool id becomes `server__tool`), `irreversible=`, `strict=` (raise `KyaDenied`, and block `REQUIRE_APPROVE`), `fail_closed=`, `timeout=`.
|
|
58
|
+
|
|
59
|
+
Raw LangGraph graph:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
kya = KyaMiddleware()
|
|
63
|
+
ToolNode(tools, wrap_tool_call=kya.wrap_tool_call, awrap_tool_call=kya.awrap_tool_call)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Pydantic AI (agent-wide)
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from pydantic_ai import Agent
|
|
70
|
+
from shield_kya.integrations.pydantic_ai import KyaCapability
|
|
71
|
+
|
|
72
|
+
agent = Agent(model, tools=[...], capabilities=[KyaCapability()])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The capability hooks `before_tool_execute`, so every function tool the agent runs (toolsets and MCP servers included, but see Known limits on MCP names) is checked before it executes, with the CLI run as an asyncio subprocess (`run_sync` included, since Pydantic AI runs tools on its event loop). A `DENY` raises `SkipToolExecution` with the reason, so the model receives `KYA denied postgres__drop_table: NEVER_EVENT` as the tool result and the run continues. `strict=True` raises `KyaDenied` instead, which aborts the run, and also blocks `REQUIRE_APPROVE`. Output tools skip capability hooks and are not checked. The capability is per agent: add `KyaCapability()` to every agent, including agents another agent runs as a tool (subagents). Put `KyaCapability` last in `capabilities` so the trail records the args as they will execute. Options: `server=`, `irreversible=`, `strict=`, `fail_closed=`, `timeout=`.
|
|
76
|
+
|
|
77
|
+
## CrewAI (process-wide)
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from shield_kya.integrations import crewai as kya_crewai
|
|
81
|
+
|
|
82
|
+
kya_crewai.register() # every tool call of every crew in this process
|
|
83
|
+
crew.kickoff()
|
|
84
|
+
kya_crewai.unregister() # optional
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`register()` adds a global `PRE_TOOL_CALL` hook and a matching `POST_TOOL_CALL` hook; calling it again replaces them. CrewAI swallows every hook exception except `HookAborted` and then runs the tool, so a deny is always a `HookAborted`: the tool does not run, and the post hook replaces CrewAI's fixed "Tool execution blocked by hook" text with the KYA reason, so the agent sees it and the crew continues. For the same reason `strict=True` blocks `REQUIRE_APPROVE` but never raises `KyaDenied`, and an unexpected error inside the hook still goes through the fail-open / never-list / fail-closed rules instead of reaching CrewAI. The tool id is the tool's own name: CrewAI's LLM-facing name is sanitized (`postgres__drop_table` becomes `postgres_drop_table`), which the never-list would not recognize. Call `register()` after your own pre-tool hooks so the check sees their input edits. Options: `server=`, `irreversible=`, `strict=`, `fail_closed=`, `timeout=`.
|
|
88
|
+
|
|
89
|
+
## Google ADK (Runner-wide)
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from google.adk.apps import App
|
|
93
|
+
from shield_kya.integrations.adk import KyaPlugin
|
|
94
|
+
|
|
95
|
+
app = App(name="my_app", root_agent=agent, plugins=[KyaPlugin()])
|
|
96
|
+
runner = InMemoryRunner(app=app) # or Runner(app=app, ...)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`KyaPlugin` is a `BasePlugin` whose `before_tool_callback` checks every tool call the Runner dispatches, with the CLI run as an asyncio subprocess (`Runner.run` included, since ADK runs tools on its event loop). A `DENY` returns `{"error": "KYA denied postgres__drop_table: NEVER_EVENT"}`, which ADK uses as the tool result without running the tool, so the model reads the reason and the run continues. ADK re-raises any exception from a plugin as a `RuntimeError` that aborts the run, so the plugin never lets one escape: a shim error goes through the fail-open / never-list / fail-closed rules. `strict=True` raises `KyaDenied` on purpose (ADK wraps it in that `RuntimeError`) and also blocks `REQUIRE_APPROVE`.
|
|
100
|
+
|
|
101
|
+
Covered (one plugin on the App): every agent the Runner runs, including `sub_agents` reached by `transfer_to_agent`, an `AgentTool` with its default `include_plugins=True`, MCP toolsets and long-running tools (the first call is checked), all tested; live (bidi) mode and workflow tool nodes call the same plugin hook in the google-adk 2.11.0 source (not tested here). ADK's own `transfer_to_agent` and `AgentTool` calls are checked too (UNKNOWN_TOOL, advisory; `strict=True` blocks them). Not covered: `AgentTool(include_plugins=False)`, a separate `Runner` or `App` without the plugin, tools the model provider runs server-side (`google_search`, built-in code execution), an agent `code_executor` running model-written code (not a tool call), and direct `tool.run_async()` calls. Plugins run in list order and an agent's own `before_tool_callback` runs after them, so put `KyaPlugin` last in `plugins` and do not rewrite tool args in agent callbacks. Options: `server=`, `irreversible=`, `strict=`, `fail_closed=`, `timeout=`, `name=` (plugin name, default `shield_kya`).
|
|
102
|
+
|
|
103
|
+
## Microsoft Agent Framework (agent-wide)
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from agent_framework import Agent
|
|
107
|
+
from shield_kya.integrations.agent_framework import KyaMiddleware
|
|
108
|
+
|
|
109
|
+
agent = Agent(client=client, tools=[...], middleware=[KyaMiddleware()])
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`KyaMiddleware` is a `FunctionMiddleware` (package `agent-framework-core`, the AutoGen successor). Before a function runs it is checked with the CLI as an asyncio subprocess. A `DENY` sets `context.result` to the reason and does not call `call_next()`, so the function never runs, the model receives `KYA denied postgres__drop_table: NEVER_EVENT` as the function result and the run continues. Agent Framework turns an ordinary exception from middleware into a tool-error result (the tool does not run), so the middleware never raises one: a shim error goes through the fail-open / never-list / fail-closed rules instead of silently blocking a harmless tool, and a tool's own exception stays the tool's. `strict=True` raises `MiddlewareFailure` (from `KyaDenied`), Agent Framework's fail-closed signal that aborts the run, and also blocks `REQUIRE_APPROVE`. Hosted `fail_closed=True` / `KYA_FAIL_CLOSED=1` blocks the tool the same way as a deny and keeps the run going.
|
|
113
|
+
|
|
114
|
+
Covered: every function call of that agent's function-invocation loop (`run` and `run(stream=True)`), and MCP functions (tested); approved replays of `approval_mode="always_require"` tools go through the same middleware in the agent-framework-core 1.21.0 source (not tested here). Middleware on the chat client (`client = SomeClient(middleware=[KyaMiddleware()])`) covers every agent that uses the client. Not covered: an agent wrapped with `as_tool()` unless that agent (or its client) has its own `KyaMiddleware` (the `as_tool` call itself is checked), hosted tools the service runs (code interpreter, web search, hosted MCP), and direct `FunctionTool.invoke()` calls. The first middleware in the list is the outermost, so put `KyaMiddleware` last: argument-repair middleware must run before it (Agent Framework's own rule for security middleware). Options: `server=`, `irreversible=`, `strict=`, `fail_closed=`, `timeout=`.
|
|
115
|
+
|
|
116
|
+
## OpenAI Agents SDK (every function tool present at attach() time, except SandboxAgent tools added at run time)
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from agents import Agent, Runner
|
|
120
|
+
from shield_kya.integrations.openai_agents import attach
|
|
121
|
+
|
|
122
|
+
agent = attach(Agent(name="a", tools=[...], mcp_servers=[...], handoffs=[...]))
|
|
123
|
+
Runner.run_sync(agent, "...")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The SDK has no agent-wide pre-tool hook that can skip a tool (`RunHooks.on_tool_start` can only abort the whole run), only per-tool `tool_input_guardrails`, so `attach()` puts one KYA guardrail (named `shield_kya`) on every `FunctionTool` in `agent.tools` and on every MCP server in `agent.mcp_servers`, and walks `handoffs` (an `Agent` or a `handoff(agent)`) and `agent.as_tool()` agents recursively. The SDK copies a server's guardrails onto every MCP tool it lists, so MCP tools a server adds later are covered without another call.
|
|
127
|
+
|
|
128
|
+
**Not covered.** Shell and file-editing tools: `ShellTool`, `LocalShellTool`, `ApplyPatchTool`, `ComputerTool`, `CustomTool`, and the shell, filesystem, apply-patch and image tools a `SandboxAgent` adds at run time (sandbox capabilities, created after `attach()`). An agent with them runs commands and edits files without a KYA check; for coding agents the shell and apply-patch tools are the dangerous ones, and this integration does not gate them. Also not covered: hosted tools the OpenAI service runs (`WebSearchTool`, `FileSearchTool`, `CodeInterpreterTool`, `ImageGenerationTool`, `HostedMCPTool`, `ToolSearchTool`), the handoff call itself (only the target agent's tools are checked), the commands Codex runs inside the experimental `codex_tool()` (the `codex` call itself is checked), function tools added to `agent.tools` after `attach()` (call it again), agents reached only through a custom `Handoff` without an agent reference, and direct `on_invoke_tool` calls. `ProgrammaticToolCallingTool` was not checked.
|
|
129
|
+
|
|
130
|
+
`attach()` returns the agent and is idempotent: a second call replaces the KYA guardrail and keeps your own guardrails. For one tool, `kya_tool_input_guardrail()` returns the guardrail to put in `tool_input_guardrails=[...]` yourself; for an MCP server use `attach([server])` or pass `mcp_server_name=server.name`, because the guardrail cannot read the server from the call (without it `drop_table` is checked alone and the never-list cannot tell it is `postgres__drop_table`).
|
|
131
|
+
|
|
132
|
+
Before a tool runs, the guardrail checks the call with the CLI as an asyncio subprocess (`Runner.run_sync` included, since the SDK runs guardrails on its event loop). A `DENY` returns `ToolGuardrailFunctionOutput.reject_content(reason)`: the tool does not run, the model receives `KYA denied postgres__drop_table: NEVER_EVENT` as the tool output and the run continues. The SDK aborts the run on any exception from a guardrail, so the guardrail never raises one: a shim error goes through the fail-open / never-list / fail-closed rules. `strict=True` returns `raise_exception` instead (the SDK raises `ToolInputGuardrailTripwireTriggered` and the run aborts) and also blocks `REQUIRE_APPROVE`. Hosted `fail_closed=True` / `KYA_FAIL_CLOSED=1` blocks the tool the same way as a deny and keeps the run going.
|
|
133
|
+
|
|
134
|
+
The tool id is the name the model called (`ToolContext.tool_name`), with the arguments the tool runs with. The never-list is also matched against `<namespace>.<name>` for a `tool_namespace()` tool and, for an MCP tool, against `<server name>__<tool name>`: an MCP server created with `name="postgres"` exposes `drop_table` (or `mcp_postgres__drop_table` with `mcp_config={"include_server_in_tool_names": True}`), and the check matches it as `postgres__drop_table` (the deny reason names both). Covered (tested): function tools of the agent, of handoff targets and of `as_tool()` agents, MCP server tools (both naming modes), namespaced tools, `Runner.run`, `Runner.run_sync` and `Runner.run_streamed`. Realtime agents run the same tool input guardrails in the openai-agents 0.23.1 source (not tested here). With `RunConfig(tool_execution=...)` `pre_approval_tool_input_guardrails=True` the guardrail runs before approval and again at execution, so an approved call has two trail rows. Options: `server=` (function tools only: an MCP tool already carries its server name), `irreversible=`, `strict=`, `fail_closed=`, `timeout=`.
|
|
135
|
+
|
|
136
|
+
## Tested versions
|
|
137
|
+
|
|
138
|
+
| Framework | Entry point | Tested with |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| LangChain / LangGraph | `KyaMiddleware` | langchain 1.4.4 (langchain-core 1.6.9, langgraph 1.2.14, langgraph-prebuilt 1.1.0) |
|
|
141
|
+
| Pydantic AI | `KyaCapability` | pydantic-ai-slim 2.55.0 (Python 3.12, 3.13) |
|
|
142
|
+
| CrewAI | `register()` | crewai 1.15.27 (Python 3.12, 3.13; crewai needs Python < 3.14) |
|
|
143
|
+
| Google ADK | `KyaPlugin` | google-adk 2.11.0 (Python 3.12) |
|
|
144
|
+
| Microsoft Agent Framework | `KyaMiddleware` | agent-framework-core 1.21.0 (Python 3.12) |
|
|
145
|
+
| OpenAI Agents SDK | `attach()` | openai-agents 0.23.1 (Python 3.12) |
|
|
146
|
+
|
|
147
|
+
CI runs the unit tests of all six at the versions above, and the real-CLI e2e (`tests/test_e2e_*.py`) for Pydantic AI, CrewAI, Google ADK, Agent Framework and the OpenAI Agents SDK; the LangChain e2e is still a manual gate.
|
|
148
|
+
|
|
149
|
+
## Known limits
|
|
150
|
+
|
|
151
|
+
- `KYA_CLI` set to an absolute path is trusted as is: the shim runs it. Its verdict cannot ALLOW a never-list tool (the never-list is matched in Python on every call), but the program itself still runs.
|
|
152
|
+
- crewai calls `load_dotenv()` at import. From `python -c`, a notebook or a debugger it reads the current directory's `.env`, otherwise the first `.env` above crewai's install directory (a `.venv` inside a repo reaches the repo's `.env`). A cloned repo can therefore set `KYA_CLI` for a CrewAI process (the never-list still blocks, see above). `load_dotenv()` does not replace variables that are already set, so exporting `KYA_CLI` yourself keeps a repo `.env` from choosing it. python-dotenv also expands `${PWD}` and other variables, so a `.env` can name absolute paths inside the repo. The child's `PATH` and `HOME` are only safe because crewai calls `load_dotenv()` without `override=True`, so an exported value wins; a `.env` that sets `HOME` or `PATH` for a process where they are unset is a risk the shim does not defend against.
|
|
153
|
+
- An empty tool id is denied (`EMPTY_TOOL`), like the CLI's offline evaluate; `kya hook` allows a hook payload that has no tool name.
|
|
154
|
+
- A grandchild that leaves the CLI's process group and keeps its output open can stretch a timeout past the 4 s budget. The real CLI's detached helpers do not hold its output.
|
|
155
|
+
- The child's PATH keeps only absolute entries, so a `#!/usr/bin/env node` launcher cannot pick up a repo-planted `./node`.
|
|
156
|
+
- CrewAI hooks are synchronous. Under `crew.akickoff()` the check blocks the event loop for up to `timeout` (4 s by default) per tool call; there is no async hook to use instead.
|
|
157
|
+
- CrewAI's `register()` is process-wide: it covers every crew and agent in the process, not one crew.
|
|
158
|
+
- The CrewAI reason hand-off from the pre hook to the post hook is per thread. If another pre-tool hook blocks a call before ours runs, CrewAI's own text stays (the tool is still blocked).
|
|
159
|
+
- Google ADK: `adk run`, `adk web` and `adk api_server` load the agent folder's `.env` with `load_dotenv(override=True)`, so under those commands a repo `.env` can replace `PATH`, `HOME`, `KYA_CLI` and `KYA_FAIL_CLOSED` (the allowlist above does not help: these are the values it forwards or reads). The never-list still blocks by name, but a planted `KYA_CLI` or `PATH` entry runs. In the google-adk 2.11.0 source only those CLI commands load a `.env`; a program that builds its own `Runner` does not (other libraries it imports may). agent-framework-core reads a `.env` only when you pass `env_file_path` (settings read it without changing `os.environ`; the observability setup loads it without `override`).
|
|
160
|
+
- The never-list only recognizes MCP tools named `server__tool` (or `server.tool`, `server/tool`, `mcp__server__tool`) with a known server. Google ADK and Agent Framework keep the original MCP name, so their plugin and middleware also check that name and `<prefix>__<original>`: an ADK toolset or Agent Framework `MCPTool` with `tool_name_prefix="postgres"` exposes `postgres_drop_table`, and the check matches it as `postgres__drop_table` (the deny reason names both). Without a prefix, an MCP tool is just `drop_table` in both frameworks and the never-list cannot tell which server it belongs to (`server=` applies one prefix to every tool). Agent Framework replaces characters outside `A-Za-z0-9_.-` with `-` in the exposed name; the raw remote name is checked as well. A prefix that names a known server can also block a harmless tool whose name contains a never-list verb (`github_transfer_issue` is checked as `github__transfer_issue`), the same over-block the never-list has for `server__tool` names. The other frameworks' own MCP names do not match: Pydantic AI `load_mcp_servers()` / `toolset.prefixed("postgres")` gives `postgres_drop_table` (one `_`), and CrewAI `mcps=` names tools `<server_name>_<tool>`, where a stdio `server_name` is the command line and an HTTP one the host, so CrewAI MCP tools are never matched. Those calls are still evaluated and recorded, but run (UNKNOWN_TOOL is advisory). What to do: name MCP tools in the `server__tool` form (Pydantic AI `toolset.prefixed("postgres_")` gives `postgres__drop_table`; LangChain `server=`), or rely on hosted enforcement.
|
|
161
|
+
- CrewAI MCP tool ids are the original tool names, which for stdio servers embed the full command line, including any DSN password (`postgresql://user:pass@...`). That id goes on the kya argv (visible in `ps`), into `~/.kya/trail.jsonl` and into the deny reason. Keep secrets out of MCP command lines (use the server's env instead) when you use `register()`.
|
|
162
|
+
- Pydantic AI output tools are not checked.
|
|
163
|
+
- Agent Framework: `KyaMiddleware` on the chat client always runs outside the agent's own function middleware (agent-framework-core 1.21.0 puts client middleware first), so agent-level middleware can rewrite the arguments after the check. With `KyaMiddleware` on the client, put argument-rewriting function middleware on the client too, before `KyaMiddleware`, not on the agent.
|
|
164
|
+
- `server=` on the agent-wide Google ADK, Agent Framework and OpenAI Agents SDK hooks prefixes the tool id sent to the CLI, but the never-list also matches every name without that prefix, so a prefix it does not know (`server="acme"`) cannot hide `postgres__drop_table`; the denied call is recorded under the unprefixed id.
|
|
165
|
+
- Google ADK: the original MCP name is read from `McpTool.raw_mcp_tool` (public in google-adk 2.11.0), else the private `_mcp_tool`. The tests use stand-in objects with one or the other attribute, not a real `McpTool`.
|
|
166
|
+
- Google ADK: `ManagedAgent` tools and `RemoteMcpServer` tools run on Google's backend (ADK never connects to that MCP server), so they never reach `before_tool_callback` and are not checked.
|
|
167
|
+
- OpenAI Agents SDK: the guardrail is per tool, so a function tool added to `agent.tools` after `attach()` is not checked until `attach()` runs again (MCP tools are, through the server). An MCP tool name the SDK shortened with a hash (server plus tool name over 64 characters) is not matched by its `<server>__<tool>` form. An MCP server name or a `tool_namespace()` name that is a known server can also block a harmless tool whose name contains a never-list verb (server `github` with `transfer_issue` is checked as `github__transfer_issue`), the same over-block as above. Options live on each tool and server object: when two agents share one, the last `attach()` wins for both (`attach(a, fail_closed=True)` then `attach(b)` makes a shared tool fail open for `a` too). Shell and file-editing tools (`ShellTool`, `LocalShellTool`, `ApplyPatchTool`, `ComputerTool`, `CustomTool`, `SandboxAgent` run-time tools) are not checked; see Not covered in the OpenAI Agents SDK section.
|
|
168
|
+
- The CLI child gets an allowlist of the environment, nothing else: an absolute-only `PATH`, `HOME`, `USERPROFILE`, `LANG`, `LC_*`, `TZ`, on Windows also `SYSTEMROOT`, `WINDIR`, `COMSPEC`, `PATHEXT`, `APPDATA`, `LOCALAPPDATA`, `TEMP` and `TMP`, plus `KYA_SKIP_NODE_CHECK=1` set by the shim (`TMPDIR` is left out). A framework can load a repo `.env` into `os.environ` (crewai does at import), and other variables would run repo code in node (`NODE_OPTIONS=--require ./x.js`, `OPENSSL_CONF` loading a provider library, `LD_PRELOAD`), move the trail (`KYA_HOME`) or send trail events to a webhook (`KYA_NOTIFY_WEBHOOK`, `KYA_OTLP_EXPORT_ENDPOINT`). A user-exported value looks the same, so CLI settings such as `KYA_HOME`, `KYA_SESSION_ID`, `KYA_TELEMETRY`, `KYA_NOTIFY_WEBHOOK`, proxy and CA variables are not supported through the shim: the trail always goes to `$HOME/.kya`. The privacy opt-outs `KYA_DIFF_PREVIEW` and `KYA_SEND_PREVIEWS` are forwarded only when set to an off value (0, false, no, off), so a planted value can only remove data. `KYA_CLI` and `KYA_FAIL_CLOSED` are read by the shim itself and still work.
|
|
169
|
+
- A committed repo `.kya/config.json` still applies: the CLI runs in the agent's working directory and reads that file, so a repo can add notify webhooks or an OTLP endpoint that receive trail events (every DENY and REQUIRE_APPROVE, with tool ids and summaries). This is CLI behaviour, the same for `kya hook`, not something the shim's environment allowlist can stop. CLI follow-up: do not honor repo-level webhook config in hook mode.
|
|
170
|
+
- CrewAI: when two tools sanitize to the same name (`postgres__drop_table` and `postgres_drop_table`), CrewAI can hand the hook one and run the other, so the hook denies the call if any tool with that sanitized name on the agent, the task, the agent's executor (`mcps=`, `apps=`, `execute_task(tools=)`) or a LiteAgent is on the never-list. The harmless twin is then blocked too, with a reason that names both: `KYA denied postgres_drop_table: a tool with the same sanitized name (postgres__drop_table) is on the never-list (NEVER_EVENT)`; the trail row is the DENY for `postgres__drop_table`.
|
|
171
|
+
- CrewAI `strict=True` also blocks CrewAI's own delegation tools (`delegate_work_to_coworker`, `ask_question_to_coworker`), which evaluate as REQUIRE_APPROVE UNKNOWN_TOOL.
|
|
172
|
+
- The legacy per-tool wrappers (`governed_tool`, `wrap_tool`, `wrap_tools`) still raise `KyaDenied` on a deny, which aborts a Pydantic AI run; prefer the agent-wide entry points.
|
|
173
|
+
|
|
174
|
+
## Core API
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
import shield_kya
|
|
178
|
+
|
|
179
|
+
d = shield_kya.decide("web_search", {"q": "kya"}) # never raises; records the trail
|
|
180
|
+
d.allowed, d.verdict, d.reason_code, d.reason
|
|
181
|
+
d = await shield_kya.adecide("web_search", {"q": "kya"}) # async, off the event loop
|
|
182
|
+
|
|
183
|
+
shield_kya.evaluate("org.sample.data.write", args={"table": "users"}) # raw eval-tool dict; raises on CLI errors
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`@governed` gates a sync or async callable. A plain function has no other way to report a deny, so `DENY` raises `KyaDenied` before the function runs:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from shield_kya import governed, KyaDenied
|
|
190
|
+
|
|
191
|
+
@governed("org.sample.data.write", irreversible=True)
|
|
192
|
+
def write_users_table(rows): ...
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
write_users_table(rows)
|
|
196
|
+
except KyaDenied as denied:
|
|
197
|
+
print(denied.verdict, denied.reason_code)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Per-tool wrappers
|
|
201
|
+
|
|
202
|
+
Prefer the agent-wide entry points above. These wrap single tools and share the `governed` semantics (`DENY` raises `KyaDenied`). Framework imports are lazy.
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
from shield_kya.integrations.langgraph import governed_tool # LangGraph / LangChain @tool
|
|
206
|
+
tools = [governed_tool("web.fetch")(fetch_fn)]
|
|
207
|
+
|
|
208
|
+
from shield_kya.integrations.crewai import wrap_tool # CrewAI BaseTool or callable
|
|
209
|
+
safe_search = wrap_tool(search_tool, tool_id="search.web")
|
|
210
|
+
|
|
211
|
+
from shield_kya.integrations.pydantic_ai import governed_tool # Pydantic AI
|
|
212
|
+
|
|
213
|
+
@agent.tool_plain
|
|
214
|
+
@governed_tool("search.web")
|
|
215
|
+
def search_web(q: str) -> str: ...
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
A raised `KyaDenied` aborts a Pydantic AI run (Pydantic AI does not turn tool exceptions into tool errors).
|
|
219
|
+
|
|
220
|
+
Extras (`pip install '.[name]'` from a checkout): `langchain`, `langgraph`, `crewai` (>=1.15.27,<2), `pydantic-ai` (pydantic-ai-slim >=2.55.0,<3), `adk` (google-adk >=2.11.0,<3), `agent-framework` (agent-framework-core >=1.21.0,<2), `openai-agents` (openai-agents >=0.23.1,<0.24).
|
|
221
|
+
|
|
222
|
+
## Tests
|
|
223
|
+
|
|
224
|
+
```sh
|
|
225
|
+
python -m unittest discover -s tests # stdlib suite; framework tests run when the framework is installed
|
|
226
|
+
KYA_E2E_BIN=/dir/with/kya python -m unittest discover -s tests -p 'test_e2e_*.py' # real-CLI e2e
|
|
227
|
+
KYA_E2E_BIN=/dir/with/kya python -m unittest discover -s tests -p 'test_shield_kya.py' -k RealCliEnvTest # real CLI: a planted OPENSSL_CONF never reaches node (POSIX, needs cc for the full check)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "shield-kya"
|
|
7
|
+
version = "0.17.0" # minor: OpenAI Agents SDK tool input guardrail (attach)
|
|
8
|
+
description = "Audit trail and traceability for AI agents. In-process kya shim for Python agent frameworks (offline evaluate via the kya CLI)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "MFR Limited" }]
|
|
13
|
+
keywords = ["kya", "know-your-agent", "governance", "mcp", "agents"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Security",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/The-Pixel-Boys/shield-kya"
|
|
26
|
+
Repository = "https://github.com/The-Pixel-Boys/shield-kya"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
# Tested with langchain 1.4.4 (langchain-core 1.6.9, langgraph 1.2.14, langgraph-prebuilt 1.1.0).
|
|
30
|
+
langchain = ["langchain>=1.4.4,<2"]
|
|
31
|
+
langgraph = ["langgraph"]
|
|
32
|
+
# Tested with crewai 1.15.27 and pydantic-ai-slim 2.55.0.
|
|
33
|
+
crewai = ["crewai>=1.15.27,<2"]
|
|
34
|
+
pydantic-ai = ["pydantic-ai-slim>=2.55.0,<3"]
|
|
35
|
+
# Tested with google-adk 2.11.0 and agent-framework-core 1.21.0 (the agent-framework
|
|
36
|
+
# meta package installs every provider; core has FunctionMiddleware).
|
|
37
|
+
adk = ["google-adk>=2.11.0,<3"]
|
|
38
|
+
agent-framework = ["agent-framework-core>=1.21.0,<2"]
|
|
39
|
+
# Tested with openai-agents 0.23.1 (0.x: a minor release can change the guardrail API).
|
|
40
|
+
openai-agents = ["openai-agents>=0.23.1,<0.24"]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools]
|
|
43
|
+
packages = ["shield_kya", "shield_kya.integrations"]
|