recusal-crewai 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .venv/
12
+ venv/
13
+ env/
14
+
15
+ # Secrets, never commit
16
+ .env
17
+ .env.*
18
+ *.key
19
+ *.pem
20
+ *.p12
21
+ *service-account*.json
22
+ secrets/
23
+
24
+ # OS / IDE
25
+ .DS_Store
26
+ Thumbs.db
27
+ .vscode/
28
+ .idea/
29
+
30
+ # Local hook activation, the repo ships .claude/settings.json.example only;
31
+ # copying it to settings.json is a deliberate per-clone step, never committed
32
+ .claude/settings.json
33
+ .claude/settings.local.json
34
+
35
+ # Local working docs, not part of the published project
36
+ linkedin.md
37
+ NOTES.md
38
+ RUNBOOK.local.md
39
+ ESSAY.local.md
40
+ SOURCES.local.md
41
+ *.local.md
42
+
43
+ # Engineering Harness local state, internal planning notes
44
+ .claude/harness/
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or Derivative
95
+ Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and do
117
+ not modify the License. You may add Your own attribution notices
118
+ within Derivative Works that You distribute, alongside or as an
119
+ addendum to the NOTICE text from the Work, provided that such
120
+ additional attribution notices cannot be construed as modifying
121
+ the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Philip Paz
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: recusal-crewai
3
+ Version: 0.1.0
4
+ Summary: Recusal gate for CrewAI: judge every tool call in CrewAI's own tool-call hooks, block a refusal before it runs, and optionally put each decision on a hash-chained audit log.
5
+ Project-URL: Homepage, https://github.com/philpaz/recusal
6
+ Project-URL: Documentation, https://github.com/philpaz/recusal/tree/main/adapters/crewai
7
+ Project-URL: Repository, https://github.com/philpaz/recusal
8
+ Project-URL: Issues, https://github.com/philpaz/recusal/issues
9
+ Project-URL: Changelog, https://github.com/philpaz/recusal/blob/main/CHANGELOG.md
10
+ Author-email: Philip Paz <philip.paz@gmail.com>
11
+ License-Expression: Apache-2.0
12
+ License-File: LICENSE
13
+ Keywords: agent-governance,ai-agents,crewai,guardrails,recusal,tool-use
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Quality Assurance
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: crewai<2,>=1.15.34
24
+ Requires-Dist: recusal<1,>=0.14.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # recusal-crewai
28
+
29
+ The [Recusal](https://github.com/philpaz/recusal) gate for
30
+ [CrewAI](https://docs.crewai.com/): every tool call a crew makes is judged by your policy
31
+ before the tool runs, and a refused call never runs.
32
+
33
+ This is a separate distribution from `recusal`. The kernel has zero runtime dependencies
34
+ and a frozen compatibility perimeter; this package carries the CrewAI dependency and its
35
+ own version, so a CrewAI release never forces a release of the kernel.
36
+
37
+ **Status: not yet published to PyPI.** Until it is, install it from the repository:
38
+
39
+ ```bash
40
+ pip install "recusal-crewai @ git+https://github.com/philpaz/recusal#subdirectory=adapters/crewai"
41
+ ```
42
+
43
+ Requires Python 3.10+ (CrewAI's minimum), `recusal>=0.14.0` and `crewai>=1.15.34`. It is
44
+ tested on every pull request against exactly `crewai==1.15.34`, and weekly against the
45
+ newest release.
46
+
47
+ ## Use
48
+
49
+ ```python
50
+ from recusal import Finding
51
+ from recusal_crewai import install_recusal_hooks
52
+
53
+
54
+ def policy(tool_name, tool_input):
55
+ sql = tool_input.get("sql", "")
56
+ if tool_name == "run_sql" and sql.lstrip().upper().startswith("DELETE"):
57
+ return [Finding.fail("no_deletes", severity="CRITICAL", message="DELETE is not allowed")]
58
+ return []
59
+
60
+
61
+ uninstall = install_recusal_hooks(policy) # after your other hooks
62
+ try:
63
+ crew.kickoff()
64
+ finally:
65
+ uninstall()
66
+ ```
67
+
68
+ The gate is CrewAI's own tool-call hooks. The before hook judges the call and blocks a
69
+ refusal; the after hook replaces CrewAI's fixed "Tool execution blocked by hook" line with
70
+ Recusal's reason, so the model reads why on its next turn. A policy that raises refuses
71
+ the call (fail closed).
72
+
73
+ **The install is process-global.** CrewAI's hook registry covers every agent, task and
74
+ crew in the process, including a coworker an agent delegates to, and every tool kind
75
+ CrewAI runs through its hooks.
76
+
77
+ **Install the gate after your other hooks.** Hooks run in registration order, and a hook
78
+ registered after the gate can change the arguments the gate judged.
79
+
80
+ The policy judges the invocation the tool receives, not the dict the model proposed:
81
+ omitted defaults are completed from the tool's schema and values are validated once, the
82
+ same way CrewAI prepares them at execution.
83
+
84
+ ## Put every decision on the record
85
+
86
+ ```python
87
+ from recusal.audit import AuditLog
88
+ from recusal_crewai import install_audited_hooks
89
+
90
+ log = AuditLog(path="decisions.jsonl")
91
+ uninstall = install_audited_hooks(policy, log)
92
+ ```
93
+
94
+ Each call the before hook sees, allowed or refused, is one hash-chained entry labeled
95
+ `crewai.before_tool_call` with a SHA-256 fingerprint of the arguments (never the
96
+ arguments). If the log cannot be written the call is refused. Check the log with
97
+ `recusal audit verify decisions.jsonl`.
98
+
99
+ **Entries carry no call id.** CrewAI's hook context has no id for the call, so the record
100
+ does not invent one. Entries are told apart by their order on the chain, the tool, the
101
+ fingerprint, and `control.crewai_agent`, the role of the agent that made the call.
102
+ CrewAI runs one turn's tool calls on a thread pool, so the order of a turn's entries on
103
+ the chain is not the order the model proposed them.
104
+
105
+ ## What the gate refuses, and what it does not see
106
+
107
+ - **Refused before the policy runs:** a tool or schema that is not CrewAI's stock one (a
108
+ schema with a validator, an alias, a constraint, a `default_factory` or an intermediate
109
+ base; a tool class that overrides CrewAI's run or validation methods), an unsupported
110
+ annotation, an unknown or missing parameter, a validation failure, and a callable that
111
+ declares a parameter its schema omits.
112
+ - **Not judged:** what a tool does once it runs. A tool that executes code or shell it is
113
+ handed needs a policy that names it.
114
+ - **Not on the audit log:** a call a hook registered before the gate blocked, and whatever
115
+ a hook registered after the gate changed.
116
+
117
+ ## Compatibility
118
+
119
+ This package is outside the kernel's frozen perimeter (`STABILITY.md`, Integrations). Its
120
+ public names are the ones in `recusal_crewai.__all__`. When a CrewAI release changes what
121
+ the hooks are handed, this package gets a release and the kernel does not.
122
+
123
+ A runnable offline demonstration, with a scripted model and no API key, is
124
+ [`examples/crewai_gate.py`](https://github.com/philpaz/recusal/blob/main/examples/crewai_gate.py)
125
+ and
126
+ [`examples/crewai_audit.py`](https://github.com/philpaz/recusal/blob/main/examples/crewai_audit.py).
@@ -0,0 +1,100 @@
1
+ # recusal-crewai
2
+
3
+ The [Recusal](https://github.com/philpaz/recusal) gate for
4
+ [CrewAI](https://docs.crewai.com/): every tool call a crew makes is judged by your policy
5
+ before the tool runs, and a refused call never runs.
6
+
7
+ This is a separate distribution from `recusal`. The kernel has zero runtime dependencies
8
+ and a frozen compatibility perimeter; this package carries the CrewAI dependency and its
9
+ own version, so a CrewAI release never forces a release of the kernel.
10
+
11
+ **Status: not yet published to PyPI.** Until it is, install it from the repository:
12
+
13
+ ```bash
14
+ pip install "recusal-crewai @ git+https://github.com/philpaz/recusal#subdirectory=adapters/crewai"
15
+ ```
16
+
17
+ Requires Python 3.10+ (CrewAI's minimum), `recusal>=0.14.0` and `crewai>=1.15.34`. It is
18
+ tested on every pull request against exactly `crewai==1.15.34`, and weekly against the
19
+ newest release.
20
+
21
+ ## Use
22
+
23
+ ```python
24
+ from recusal import Finding
25
+ from recusal_crewai import install_recusal_hooks
26
+
27
+
28
+ def policy(tool_name, tool_input):
29
+ sql = tool_input.get("sql", "")
30
+ if tool_name == "run_sql" and sql.lstrip().upper().startswith("DELETE"):
31
+ return [Finding.fail("no_deletes", severity="CRITICAL", message="DELETE is not allowed")]
32
+ return []
33
+
34
+
35
+ uninstall = install_recusal_hooks(policy) # after your other hooks
36
+ try:
37
+ crew.kickoff()
38
+ finally:
39
+ uninstall()
40
+ ```
41
+
42
+ The gate is CrewAI's own tool-call hooks. The before hook judges the call and blocks a
43
+ refusal; the after hook replaces CrewAI's fixed "Tool execution blocked by hook" line with
44
+ Recusal's reason, so the model reads why on its next turn. A policy that raises refuses
45
+ the call (fail closed).
46
+
47
+ **The install is process-global.** CrewAI's hook registry covers every agent, task and
48
+ crew in the process, including a coworker an agent delegates to, and every tool kind
49
+ CrewAI runs through its hooks.
50
+
51
+ **Install the gate after your other hooks.** Hooks run in registration order, and a hook
52
+ registered after the gate can change the arguments the gate judged.
53
+
54
+ The policy judges the invocation the tool receives, not the dict the model proposed:
55
+ omitted defaults are completed from the tool's schema and values are validated once, the
56
+ same way CrewAI prepares them at execution.
57
+
58
+ ## Put every decision on the record
59
+
60
+ ```python
61
+ from recusal.audit import AuditLog
62
+ from recusal_crewai import install_audited_hooks
63
+
64
+ log = AuditLog(path="decisions.jsonl")
65
+ uninstall = install_audited_hooks(policy, log)
66
+ ```
67
+
68
+ Each call the before hook sees, allowed or refused, is one hash-chained entry labeled
69
+ `crewai.before_tool_call` with a SHA-256 fingerprint of the arguments (never the
70
+ arguments). If the log cannot be written the call is refused. Check the log with
71
+ `recusal audit verify decisions.jsonl`.
72
+
73
+ **Entries carry no call id.** CrewAI's hook context has no id for the call, so the record
74
+ does not invent one. Entries are told apart by their order on the chain, the tool, the
75
+ fingerprint, and `control.crewai_agent`, the role of the agent that made the call.
76
+ CrewAI runs one turn's tool calls on a thread pool, so the order of a turn's entries on
77
+ the chain is not the order the model proposed them.
78
+
79
+ ## What the gate refuses, and what it does not see
80
+
81
+ - **Refused before the policy runs:** a tool or schema that is not CrewAI's stock one (a
82
+ schema with a validator, an alias, a constraint, a `default_factory` or an intermediate
83
+ base; a tool class that overrides CrewAI's run or validation methods), an unsupported
84
+ annotation, an unknown or missing parameter, a validation failure, and a callable that
85
+ declares a parameter its schema omits.
86
+ - **Not judged:** what a tool does once it runs. A tool that executes code or shell it is
87
+ handed needs a policy that names it.
88
+ - **Not on the audit log:** a call a hook registered before the gate blocked, and whatever
89
+ a hook registered after the gate changed.
90
+
91
+ ## Compatibility
92
+
93
+ This package is outside the kernel's frozen perimeter (`STABILITY.md`, Integrations). Its
94
+ public names are the ones in `recusal_crewai.__all__`. When a CrewAI release changes what
95
+ the hooks are handed, this package gets a release and the kernel does not.
96
+
97
+ A runnable offline demonstration, with a scripted model and no API key, is
98
+ [`examples/crewai_gate.py`](https://github.com/philpaz/recusal/blob/main/examples/crewai_gate.py)
99
+ and
100
+ [`examples/crewai_audit.py`](https://github.com/philpaz/recusal/blob/main/examples/crewai_audit.py).
@@ -0,0 +1,54 @@
1
+ # The Recusal adapter for CrewAI, a separate distribution from `recusal`.
2
+ #
3
+ # Why separate: `recusal` has zero runtime dependencies and a frozen compatibility
4
+ # perimeter (STABILITY.md). An adapter follows a framework that releases on its own
5
+ # schedule, so it carries that dependency and its own version here, and a framework's
6
+ # breaking change never forces a release of the kernel.
7
+ #
8
+ # Build backend pinned exactly, the same pin as the kernel's pyproject.toml: a floating
9
+ # backend is unreviewed code in the release path.
10
+ [build-system]
11
+ requires = ["hatchling==1.27.0"]
12
+ build-backend = "hatchling.build"
13
+
14
+ [project]
15
+ name = "recusal-crewai"
16
+ dynamic = ["version"]
17
+ description = "Recusal gate for CrewAI: judge every tool call in CrewAI's own tool-call hooks, block a refusal before it runs, and optionally put each decision on a hash-chained audit log."
18
+ readme = "README.md"
19
+ # CrewAI's own minimum.
20
+ requires-python = ">=3.10"
21
+ license = "Apache-2.0"
22
+ license-files = ["LICENSE"]
23
+ authors = [{ name = "Philip Paz", email = "philip.paz@gmail.com" }]
24
+ keywords = ["recusal", "crewai", "ai-agents", "agent-governance", "guardrails", "tool-use"]
25
+ classifiers = [
26
+ "Development Status :: 4 - Beta",
27
+ "Intended Audience :: Developers",
28
+ "Operating System :: OS Independent",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3 :: Only",
31
+ "Topic :: Security",
32
+ "Topic :: Software Development :: Quality Assurance",
33
+ "Typing :: Typed",
34
+ ]
35
+ dependencies = [
36
+ # 0.14.0 is the first release where `decide` and `recusal.invocation` are public.
37
+ "recusal>=0.14.0,<1",
38
+ # The lower bound is the version the pinned CI lane tests; the weekly latest lane
39
+ # (.github/workflows/compat.yml) tests the newest release.
40
+ "crewai>=1.15.34,<2",
41
+ ]
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/philpaz/recusal"
45
+ Documentation = "https://github.com/philpaz/recusal/tree/main/adapters/crewai"
46
+ Repository = "https://github.com/philpaz/recusal"
47
+ Issues = "https://github.com/philpaz/recusal/issues"
48
+ Changelog = "https://github.com/philpaz/recusal/blob/main/CHANGELOG.md"
49
+
50
+ [tool.hatch.version]
51
+ path = "src/recusal_crewai/__init__.py"
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/recusal_crewai"]
@@ -0,0 +1,165 @@
1
+ """Prove the INSTALLED recusal-crewai wheel gates a real CrewAI crew.
2
+
3
+ Run it from a directory outside the repository, with an interpreter that has the built
4
+ wheels installed:
5
+
6
+ python /path/to/adapters/crewai/smoke_test.py
7
+
8
+ It fails if the package it imports is the source tree and not an installed distribution:
9
+ a check run from the checkout would prove nothing about the wheel. A scripted model
10
+ proposes the calls; no provider, API key or network is used.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ import tempfile
19
+ from importlib import metadata
20
+ from pathlib import Path
21
+ from types import SimpleNamespace
22
+ from typing import Any
23
+
24
+ # offline, and quiet: no telemetry, no tracing banner. Set before CrewAI is imported.
25
+ for _var, _value in (
26
+ ("CREWAI_DISABLE_TELEMETRY", "true"),
27
+ ("CREWAI_TRACING_ENABLED", "false"),
28
+ ("OTEL_SDK_DISABLED", "true"),
29
+ ):
30
+ os.environ.setdefault(_var, _value)
31
+
32
+ # Python puts this script's directory first on the import path. Drop it, so nothing in
33
+ # the source tree can stand in for the installed package.
34
+ _HERE = Path(__file__).resolve().parent
35
+ sys.path[:] = [entry for entry in sys.path if Path(entry or ".").resolve() != _HERE]
36
+
37
+ import recusal_crewai # noqa: E402
38
+ from crewai import Agent, Crew, Task # noqa: E402
39
+ from crewai.llms.base_llm import BaseLLM # noqa: E402
40
+ from crewai.tools import tool # noqa: E402
41
+ from recusal_crewai import SURFACE, install_audited_hooks, install_recusal_hooks # noqa: E402
42
+
43
+ import recusal # noqa: E402
44
+ from recusal import Finding # noqa: E402
45
+ from recusal.audit import AuditLog, verify # noqa: E402
46
+
47
+ EXECUTED: list[str] = []
48
+ CALLS = [("c1", "SELECT 1"), ("c2", "DELETE FROM t")]
49
+
50
+
51
+ @tool("run_sql")
52
+ def run_sql(sql: str) -> str:
53
+ """Record SQL text without connecting to a database."""
54
+ EXECUTED.append(sql)
55
+ return f"recorded: {sql}"
56
+
57
+
58
+ def policy(tool_name, tool_input):
59
+ if str(tool_input.get("sql", "")).upper().startswith("DELETE"):
60
+ return [Finding.fail("no_deletes", severity="CRITICAL", message="DELETE is not allowed")]
61
+ return []
62
+
63
+
64
+ class ScriptedLLM(BaseLLM):
65
+ """Proposes the scripted calls on its first turn, then answers "done"."""
66
+
67
+ def __init__(self) -> None:
68
+ super().__init__(model="scripted")
69
+ self.turns = 0
70
+
71
+ def call(self, messages, tools=None, callbacks=None, available_functions=None, **_: Any):
72
+ self.turns += 1
73
+ if self.turns == 1:
74
+ return [
75
+ SimpleNamespace(
76
+ id=ident,
77
+ type="function",
78
+ function=SimpleNamespace(name="run_sql", arguments=json.dumps({"sql": sql})),
79
+ )
80
+ for ident, sql in CALLS
81
+ ]
82
+ return "Final Answer: done"
83
+
84
+ def supports_function_calling(self) -> bool:
85
+ return True
86
+
87
+ def supports_stop_words(self) -> bool:
88
+ return False
89
+
90
+ def get_context_window_size(self) -> int:
91
+ return 100_000
92
+
93
+
94
+ def run() -> dict[str, str]:
95
+ """Run one task through a Crew; return what the model received for each call, by id."""
96
+ EXECUTED.clear()
97
+ agent = Agent(
98
+ role="assistant",
99
+ goal="do the task",
100
+ backstory="an offline agent",
101
+ llm=ScriptedLLM(),
102
+ tools=[run_sql],
103
+ verbose=False,
104
+ )
105
+ task = Task(description="go", expected_output="done", agent=agent)
106
+ Crew(agents=[agent], tasks=[task], verbose=False).kickoff()
107
+ executor = agent.agent_executor
108
+ return {
109
+ str(message.get("tool_call_id")): str(message.get("content"))
110
+ for message in (getattr(executor.state, "messages", []) if executor is not None else [])
111
+ if isinstance(message, dict) and message.get("role") == "tool"
112
+ }
113
+
114
+
115
+ def main() -> None:
116
+ for module in (recusal, recusal_crewai):
117
+ origin = Path(module.__file__).resolve()
118
+ assert "site-packages" in origin.parts, f"{module.__name__} is not installed: {origin}"
119
+ assert metadata.version("recusal-crewai") == recusal_crewai.__version__
120
+ requires = metadata.requires("recusal-crewai") or []
121
+ assert any(r.startswith("recusal") and "crewai" not in r for r in requires), requires
122
+
123
+ # Negative control: without the hooks the DELETE runs, so the refusals below are the gate's.
124
+ run()
125
+ assert sorted(EXECUTED) == ["DELETE FROM t", "SELECT 1"], EXECUTED
126
+
127
+ uninstall = install_recusal_hooks(policy)
128
+ try:
129
+ answers = run()
130
+ finally:
131
+ uninstall()
132
+ assert EXECUTED == ["SELECT 1"], EXECUTED
133
+ assert "recorded: SELECT 1" in answers["c1"], answers
134
+ assert "DELETE is not allowed" in answers["c2"], answers
135
+
136
+ # the hooks are gone once uninstalled: the registry is process-global, so this matters
137
+ run()
138
+ assert sorted(EXECUTED) == ["DELETE FROM t", "SELECT 1"], EXECUTED
139
+
140
+ with tempfile.TemporaryDirectory() as folder:
141
+ log = AuditLog(path=os.path.join(folder, "audit.jsonl"))
142
+ uninstall = install_audited_hooks(policy, log)
143
+ try:
144
+ run()
145
+ finally:
146
+ uninstall()
147
+ assert EXECUTED == ["SELECT 1"], EXECUTED
148
+ # no call id on a CrewAI entry, and one turn's entries land in any order
149
+ decisions = sorted(entry["action"]["decision"] for entry in log.entries)
150
+ assert len(decisions) == 2 and decisions.count("deny") == 1, decisions
151
+ assert {entry["action"]["surface"] for entry in log.entries} == {SURFACE}
152
+ assert {entry["action"]["control"]["crewai_agent"] for entry in log.entries} == {
153
+ "assistant"
154
+ }
155
+ intact, problems = verify(log.entries)
156
+ assert intact, problems
157
+
158
+ print(
159
+ f"recusal-crewai {recusal_crewai.__version__} with recusal "
160
+ f"{recusal.__version__}: installed wheel gates and records"
161
+ )
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()
@@ -0,0 +1,43 @@
1
+ """Recusal for CrewAI: judge every tool call before it runs.
2
+
3
+ from recusal_crewai import install_recusal_hooks
4
+
5
+ uninstall = install_recusal_hooks(policy) # after your other hooks
6
+ crew.kickoff()
7
+
8
+ ``policy(tool_name, tool_input)`` is an ordinary Recusal policy returning findings. A
9
+ refused call is blocked, and the model reads Recusal's reason in place of CrewAI's fixed
10
+ "blocked by hook" line. CrewAI's hook registry is process-global, so one install covers
11
+ every agent, task and crew in the process. ``install_audited_hooks`` puts each decision on
12
+ a hash-chained audit log.
13
+
14
+ The decision is made by ``recusal.decide``. This package adds only what is CrewAI's: the
15
+ two hooks, how the invocation is prepared, and how a refusal is explained to the model.
16
+ """
17
+
18
+ from ._audit import SURFACE, install_audited_hooks, recording
19
+ from ._gate import (
20
+ BLOCKED_PREFIX,
21
+ PENDING_LIMIT,
22
+ Policy,
23
+ Recording,
24
+ install_recusal_hooks,
25
+ recusal_after_tool_call,
26
+ recusal_before_tool_call,
27
+ )
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "BLOCKED_PREFIX",
33
+ "PENDING_LIMIT",
34
+ "SURFACE",
35
+ "Policy",
36
+ "Recording",
37
+ "__version__",
38
+ "install_audited_hooks",
39
+ "install_recusal_hooks",
40
+ "recording",
41
+ "recusal_after_tool_call",
42
+ "recusal_before_tool_call",
43
+ ]
@@ -0,0 +1,73 @@
1
+ """The audited gate: the same decision, with every call on a hash-chained ``AuditLog``.
2
+
3
+ ``install_audited_hooks(policy, log)`` is ``install_recusal_hooks`` with a ``recording``
4
+ that passes the recording arguments through to ``recusal.decide``, which writes the entry
5
+ before returning. There is no second decision path. If the log cannot be written the
6
+ decision becomes a deny, so nothing runs unrecorded. Like the plain gate it is
7
+ process-global: one install records every agent, task and crew in the process.
8
+
9
+ What each entry carries:
10
+
11
+ - no call id. CrewAI's hook context has the tool name, the argument dict, the tool and the
12
+ agent, task and crew, and no id for the call, so the record does not invent one. Entries
13
+ are told apart by their order on the chain, the tool, the fingerprint and
14
+ ``control.crewai_agent``, the role of the agent that made the call when CrewAI supplies
15
+ it;
16
+ - for a judged call (allowed or refused by the policy): the fingerprint of the prepared
17
+ invocation CrewAI executes, defaults completed and values validated;
18
+ - for a call refused before judgment (a tool or schema the gate cannot judge, an unknown or
19
+ missing parameter, a validation failure): the fingerprint of the arguments as proposed.
20
+
21
+ What is not on the log: a call a hook registered BEFORE the gate blocked (CrewAI stops at
22
+ the first hook that blocks, so the gate never sees it and the tool does not run), and
23
+ whatever a hook registered AFTER the gate changed. Install the gate after your other hooks,
24
+ as with the plain one, so the record is of the call exactly as it runs.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import Any, Callable, Optional
30
+
31
+ from recusal.audit import AuditLog
32
+
33
+ from ._gate import Policy, Recording, install_recusal_hooks
34
+
35
+ #: The surface label every entry carries: the CrewAI hook that adjudicated.
36
+ SURFACE = "crewai.before_tool_call"
37
+
38
+
39
+ def recording(
40
+ log: AuditLog, *, actor: Optional[str] = None, control: Optional[dict[str, Any]] = None
41
+ ) -> Recording:
42
+ """The recording arguments for each call, built in one place.
43
+
44
+ CrewAI's hook context carries no call id, so none is recorded. The role of the agent
45
+ making the call is recorded as ``control.crewai_agent`` when CrewAI supplies one; a
46
+ caller's own ``control`` cannot overwrite it."""
47
+
48
+ def for_call(context: Any) -> dict[str, Any]:
49
+ identity = dict(control or {})
50
+ role = getattr(getattr(context, "agent", None), "role", None)
51
+ if isinstance(role, str) and role:
52
+ identity["crewai_agent"] = role
53
+ else:
54
+ identity.pop("crewai_agent", None)
55
+ return {
56
+ "audit": log,
57
+ "surface": SURFACE,
58
+ "actor": actor,
59
+ "control": identity or None,
60
+ }
61
+
62
+ return for_call
63
+
64
+
65
+ def install_audited_hooks(
66
+ policy: Policy,
67
+ log: AuditLog,
68
+ *,
69
+ actor: Optional[str] = None,
70
+ control: Optional[dict[str, Any]] = None,
71
+ ) -> Callable[[], None]:
72
+ """``install_recusal_hooks`` with every decision on ``log``; returns ``uninstall``."""
73
+ return install_recusal_hooks(policy, recording(log, actor=actor, control=control))
@@ -0,0 +1,475 @@
1
+ """The gate: CrewAI's own tool-call hooks, judging the invocation the tool receives.
2
+
3
+ The hook is CrewAI's own. CrewAI runs its ``before_tool_call`` hooks immediately before a
4
+ tool executes, with the tool name, the argument dict and the tool object; a hook that
5
+ returns ``False`` blocks the call. CrewAI then answers the model with a fixed line, "Tool
6
+ execution blocked by hook. Tool: <name>", and runs the ``after_tool_call`` hooks even on a
7
+ blocked call; an after hook that returns a string replaces that answer. So the gate is two
8
+ hooks that work as one: ``recusal_before_tool_call(policy)`` judges the call with
9
+ ``recusal.decide`` and blocks a refusal, and ``recusal_after_tool_call(policy)``
10
+ replaces the fixed line with the reason the before hook blocked on, so the model reads why
11
+ on its next turn. ``install_recusal_hooks(policy)`` registers both and returns the function
12
+ that removes them.
13
+
14
+ The reason is the one the before hook decided, never a second judgment: a policy may be
15
+ stateful, and the blocked call must be explained by the decision that blocked it. The two
16
+ hooks correlate by the argument dict object itself: CrewAI hands the same ``tool_input``
17
+ dict to the before and after hooks of one invocation on one thread (verified for the sync
18
+ Crew runner, where every invocation also gets a fresh dict), so the before hook keeps the
19
+ reason beside a strong reference to that object in a small per-thread list, and the after
20
+ hook returns it only for that very object. Any other case, a different object, a different
21
+ thread, an entry already used or dropped, keeps CrewAI's generic line; a reason is never
22
+ borrowed from another call, and blocking never depends on the explanation being kept.
23
+
24
+ The policy judges the invocation the tool receives, not the dict the model proposed. CrewAI
25
+ validates the dict against the tool's ``args_schema`` at execution (``model_validate`` then
26
+ ``model_dump``, on every path), which fills omitted defaults and coerces values, so a hook
27
+ that judged the proposal would judge ``{}`` for ``charge(amount: int = 100)`` while 100 ran.
28
+ The before hook therefore prepares the invocation first: omitted fields are completed from
29
+ the schema's defaults, the schema validates once, the validated values are written back
30
+ into the dict in place (the one CrewAI executes) and a copy is judged. So that this single
31
+ preparation is the one CrewAI repeats, the gate supports only a stock tool and schema: a
32
+ ``CrewStructuredTool`` whose class and whose original ``BaseTool`` keep CrewAI's own run and
33
+ validation methods, and a schema that is a direct ``BaseModel`` subclass with no members of
34
+ its own beyond what Pydantic generates, configuration limited to ``extra`` and ``title``,
35
+ and every field a bare declaration (a supported annotation, an optional JSON default, an
36
+ optional description and title; nothing else) compared against the canonical ``FieldInfo``
37
+ that declaration produces. CrewAI's own generated schemas (``@tool`` functions, ``BaseTool``
38
+ subclasses without an ``args_schema``) are exactly that. A customised schema (a validator,
39
+ an overridden ``model_validate``, an intermediate base, a field with ``exclude``,
40
+ ``exclude_if``, ``default_factory``, an alias or a constraint), an unsupported annotation
41
+ (``Any``, ``Annotated``, an ``Enum``, a model), an unknown parameter, a missing required one
42
+ and a validation failure are refused before judgment; a validation failure is reported by
43
+ field name only, never with Pydantic's text, which carries the input values.
44
+
45
+ The validated dict reaches the tool's callable as keyword arguments (``func`` on the
46
+ structured tool: the original ``BaseTool``'s ``_run``, or the application function a stock
47
+ ``Tool`` wraps), and Python fills any parameter that callable declares beyond the schema from
48
+ the callable's own default, after judgment. The gate therefore holds the callable to the
49
+ schema: every named parameter it declares must be a schema field, or the call is refused
50
+ before judgment, naming the parameter and never its default. Declare it in the schema.
51
+
52
+ CrewAI's hook registry is process-global: one install covers every agent, task and crew in
53
+ the process, including the coworker an agent delegates to, and every tool kind CrewAI runs
54
+ through its hooks (its own tools, ``@tool`` functions, MCP tools). Two consequences the tests
55
+ pin:
56
+
57
+ - hooks run in registration order and a later hook sees, and can change, the argument dict
58
+ the gate judged. Install the gate AFTER your other hooks, so it judges the call exactly as
59
+ it will run; a hook registered after it that rewrites the arguments bypasses the gate;
60
+ - the gate judges the tool name and arguments, not what a tool does once it runs. A tool
61
+ that executes code or shell it is handed (CrewAI's code interpreter, for instance) needs a
62
+ policy that names it; the gate cannot see inside.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import copy
68
+ import dataclasses
69
+ import inspect
70
+ import re
71
+ import threading
72
+ from typing import Any, Callable, List, Optional
73
+
74
+ from crewai.hooks import (
75
+ register_after_tool_call_hook,
76
+ register_before_tool_call_hook,
77
+ unregister_after_tool_call_hook,
78
+ unregister_before_tool_call_hook,
79
+ )
80
+ from crewai.hooks.tool_hooks import ToolCallHookContext
81
+ from crewai.tools import BaseTool
82
+ from crewai.tools.base_tool import Tool
83
+ from crewai.tools.structured_tool import CrewStructuredTool
84
+ from pydantic import BaseModel, Field, ValidationError
85
+ from pydantic.fields import FieldInfo
86
+
87
+ from recusal import Finding, decide
88
+ from recusal.invocation import json_data as _json_data
89
+ from recusal.invocation import supported_annotation as _supported_annotation
90
+
91
+ #: A Recusal policy: ``policy(tool_name, tool_input)`` returns a list of findings.
92
+ Policy = Callable[[str, dict], List[Any]]
93
+
94
+ #: The answer CrewAI gives the model for a call a hook blocked; the after hook replaces it.
95
+ BLOCKED_PREFIX = "Tool execution blocked by hook."
96
+
97
+
98
+ #: The names Pydantic's metaclass (and the interpreter) put on a model class that declares
99
+ #: nothing of its own. Any other own name is a customisation: a method, a validator, a
100
+ #: property, a nested class, a plain attribute. ``tests/test_crewai_gate.py`` pins that a
101
+ #: stock ``create_model`` schema and a bare class statement stay within this set.
102
+ _GENERATED_NAMES = frozenset(
103
+ {
104
+ "__annotations__",
105
+ "__module__",
106
+ "__qualname__",
107
+ "__doc__",
108
+ "__dict__",
109
+ "__weakref__",
110
+ "__abstractmethods__",
111
+ "_abc_impl",
112
+ "model_config",
113
+ "model_fields",
114
+ "model_computed_fields",
115
+ "__class_vars__",
116
+ "__private_attributes__",
117
+ "__signature__",
118
+ "__firstlineno__", # class statements on Python 3.13+
119
+ "__static_attributes__", # class statements on Python 3.13+
120
+ }
121
+ )
122
+ _PYDANTIC_FAMILY = re.compile(r"^__pydantic_\w+__$")
123
+ _ALLOWED_CONFIG = frozenset({"extra", "title"})
124
+
125
+
126
+ def _canonical_field(field: FieldInfo) -> FieldInfo:
127
+ """The ``FieldInfo`` the supported declaration produces: the annotation, a default when
128
+ the field has one, and a description or title when the field sets one; nothing else."""
129
+ extra: dict[str, Any] = {}
130
+ if field.description is not None:
131
+ extra["description"] = field.description
132
+ if field.title is not None:
133
+ extra["title"] = field.title
134
+ # Pydantic types a field's annotation as optional; a field read from a model class has one
135
+ annotation: Any = field.annotation
136
+ if field.is_required():
137
+ if extra:
138
+ return FieldInfo.from_annotated_attribute(annotation, Field(**extra))
139
+ return FieldInfo.from_annotation(annotation)
140
+ return FieldInfo.from_annotated_attribute(annotation, Field(default=field.default, **extra))
141
+
142
+
143
+ def _unsupported_field(name: str, field: FieldInfo) -> Optional[str]:
144
+ """Why this field is not a bare declaration, or None if it is."""
145
+ if not _supported_annotation(field.annotation):
146
+ return f"field `{name}` is not annotated with a supported data type"
147
+ canonical = _canonical_field(field)
148
+ # _attributes_set is every setting the declaration supplied; repr covers the metadata
149
+ # list (constraints) and the value of each setting. Equal to the canonical form means
150
+ # the declaration supplied nothing the contract does not name.
151
+ if field._attributes_set != canonical._attributes_set or repr(field) != repr(canonical):
152
+ return f"field `{name}` carries a setting beyond a default, description and title"
153
+ if not field.is_required() and not _json_data(field.default):
154
+ return f"field `{name}` has a default outside the JSON domain"
155
+ return None
156
+
157
+
158
+ def _unsupported_schema(schema: Any) -> Optional[str]:
159
+ """Why this argument schema is not a stock model, or None if it is."""
160
+ if not (isinstance(schema, type) and issubclass(schema, BaseModel)):
161
+ return "its argument schema is not a Pydantic model"
162
+ if schema.__bases__ != (BaseModel,):
163
+ return "its argument schema has a base class of its own"
164
+ own = sorted(
165
+ n for n in vars(schema) if n not in _GENERATED_NAMES and not _PYDANTIC_FAMILY.match(n)
166
+ )
167
+ if own:
168
+ return f"its argument schema defines `{own[0]}`"
169
+ decorators = getattr(schema, "__pydantic_decorators__", None)
170
+ if dataclasses.is_dataclass(decorators) and any(
171
+ getattr(decorators, f.name) for f in dataclasses.fields(decorators)
172
+ ):
173
+ return "its argument schema registers validators"
174
+ config = sorted(set(schema.model_config) - _ALLOWED_CONFIG)
175
+ if config:
176
+ return f"its argument schema sets configuration `{config[0]}`"
177
+ for name, field in schema.model_fields.items():
178
+ why = _unsupported_field(name, field)
179
+ if why is not None:
180
+ return why
181
+ return None
182
+
183
+
184
+ #: The structured tool's methods that validate and run an invocation on CrewAI's paths, and
185
+ #: the original tool's; the gate supports a tool only while every one of them is CrewAI's own.
186
+ _STOCK_STRUCTURED = ("_parse_args", "invoke", "ainvoke")
187
+ _STOCK_ORIGINAL = ("run", "arun", "_validate_kwargs")
188
+
189
+
190
+ def _unsupported_tool(tool: Any) -> Optional[str]:
191
+ """Why this tool's execution cannot be judged faithfully, or None if it can."""
192
+ if not isinstance(tool, CrewStructuredTool):
193
+ return "the call carries no CrewAI tool object"
194
+ for name in _STOCK_STRUCTURED:
195
+ if getattr(type(tool), name) is not getattr(CrewStructuredTool, name):
196
+ return f"its tool class overrides `{name}`"
197
+ original = getattr(tool, "_original_tool", None)
198
+ if original is not None:
199
+ stock = {getattr(BaseTool, n) for n in _STOCK_ORIGINAL} | {
200
+ getattr(Tool, n) for n in _STOCK_ORIGINAL
201
+ }
202
+ for name in _STOCK_ORIGINAL:
203
+ if getattr(type(original), name, None) not in stock:
204
+ return f"its tool class overrides `{name}`"
205
+ why = _unsupported_schema(tool.args_schema)
206
+ if why is not None:
207
+ return why
208
+ return _incomplete_callable(tool)
209
+
210
+
211
+ def _execution_callable(tool: Any) -> Any:
212
+ """The application callable CrewAI finally runs for this structured tool.
213
+
214
+ ``CrewStructuredTool.invoke`` calls ``func`` with the validated dict as keyword arguments.
215
+ ``BaseTool.to_structured_tool`` sets ``func`` to the original tool's bound ``_run`` and
216
+ keeps the original as ``_original_tool``; a stock ``Tool`` (what ``@tool`` builds)
217
+ forwards ``_run(*args, **kwargs)`` to the application function it wraps, whose signature
218
+ is the one Python completes. Raises ``TypeError`` when ``func`` is not the original's
219
+ ``_run``: the gate cannot say what would run.
220
+ """
221
+ func = tool.func
222
+ original = getattr(tool, "_original_tool", None)
223
+ if original is None:
224
+ return func
225
+ if func != original._run:
226
+ raise TypeError("its callable is not the original tool's `_run`")
227
+ if type(original)._run is Tool._run:
228
+ return original.func
229
+ return func
230
+
231
+
232
+ def _incomplete_callable(tool: Any) -> Optional[str]:
233
+ """Why the callable declares a parameter the schema does not, or None if it declares
234
+ none. Python would fill such a parameter from the callable's default after judgment, so
235
+ the policy would never see the value that runs; the refusal names the parameter and
236
+ never its default."""
237
+ try:
238
+ func = _execution_callable(tool)
239
+ except TypeError as exc:
240
+ return str(exc)
241
+ try:
242
+ parameters = inspect.signature(func).parameters
243
+ except (TypeError, ValueError):
244
+ return "its callable cannot be inspected for its parameters"
245
+ fields = tool.args_schema.model_fields
246
+ for name, parameter in parameters.items():
247
+ if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD):
248
+ continue
249
+ if parameter.kind is parameter.POSITIONAL_ONLY:
250
+ return f"its callable's parameter `{name}` is positional-only"
251
+ if name not in fields:
252
+ return f"its callable's parameter `{name}` is absent from the argument schema"
253
+ return None
254
+
255
+
256
+ def _prepared(
257
+ tool: Any, tool_input: dict[str, Any]
258
+ ) -> tuple[Optional[dict[str, Any]], Optional[str]]:
259
+ """Prepare the invocation the tool will execute, in place, and return a detached copy
260
+ for judgment; or ``(None, reason)`` when it cannot be prepared faithfully.
261
+
262
+ Omitted fields are completed from the schema's defaults into the raw input, the schema
263
+ validates once (the same ``model_validate`` CrewAI runs at execution, which coerces an
264
+ explicit ``"100"`` to 100 for an ``int`` field), and the validated values replace the
265
+ dict's contents, so the dict CrewAI executes is the prepared invocation and the policy
266
+ judges a copy of it. A field the schema requires and the input omits is refused, as is
267
+ an unknown parameter and a validation failure (named by field, without Pydantic's text).
268
+ """
269
+ why = _unsupported_tool(tool)
270
+ if why is not None:
271
+ return None, f"its arguments cannot be judged ({why})"
272
+ fields = tool.args_schema.model_fields
273
+ unknown = sorted(k for k in tool_input if k not in fields)
274
+ if unknown:
275
+ return None, f"`{unknown[0]}` is not a parameter of the tool"
276
+ raw: dict[str, Any] = {}
277
+ for name, field in fields.items():
278
+ if name in tool_input:
279
+ raw[name] = tool_input[name]
280
+ elif not field.is_required():
281
+ raw[name] = copy.deepcopy(field.default)
282
+ else:
283
+ return None, f"required parameter `{name}` is missing"
284
+ try:
285
+ prepared = tool.args_schema.model_validate(raw).model_dump()
286
+ except ValidationError as exc:
287
+ named = sorted({str(e["loc"][0]) for e in exc.errors() if e.get("loc")})
288
+ return None, f"validation failed for `{named[0]}`" if named else "validation failed"
289
+ except Exception: # noqa: BLE001, a schema that fails otherwise cannot be judged
290
+ return None, "validation failed"
291
+ tool_input.clear()
292
+ tool_input.update(prepared)
293
+ return copy.deepcopy(prepared), None
294
+
295
+
296
+ #: Given the hook's ``ToolCallHookContext``, the recording arguments ``decide`` takes for the
297
+ #: call (``audit=``, ``surface=``, ...). examples/crewai_audit.py builds one.
298
+ Recording = Callable[[Any], dict]
299
+
300
+
301
+ def _unjudgeable(tool_name: str, detail: str, proposal: Any, record: dict) -> str:
302
+ """The reason for a call the gate refuses before any policy runs.
303
+
304
+ Without recording arguments it is the plain text. With them (examples/crewai_audit.py)
305
+ the refusal goes through ``decide`` like every other, so it is one entry on the log,
306
+ bound to the arguments as the model proposed them, and the reason kept for the model is
307
+ the one on the record.
308
+ """
309
+ if not record:
310
+ return f"Recusal refused `{tool_name}`: {detail}"
311
+ bound = proposal if isinstance(proposal, dict) else {"input": proposal}
312
+
313
+ def unjudgeable(_name, _input):
314
+ return [Finding.fail("recusal_unjudgeable_input", severity="CRITICAL", message=detail)]
315
+
316
+ try:
317
+ _, reason = decide(tool_name, bound, unjudgeable, safe_errors=True, **record)
318
+ except Exception as exc: # noqa: BLE001, the refusal stands even if the record cannot be written
319
+ return f"Recusal failed closed (policy error): RECUSAL_POLICY_ERROR ({type(exc).__name__})"
320
+ return reason
321
+
322
+
323
+ def _refusal(
324
+ tool: Any, tool_name: str, tool_input: Any, policy: Policy, **record: Any
325
+ ) -> Optional[str]:
326
+ """Return the reason a call is refused, or None to run it.
327
+
328
+ The policy judges a copy of the prepared invocation (``_prepared``), which is also what
329
+ the dict CrewAI executes now holds. A non-dict is refused: the policy cannot read it.
330
+
331
+ ``record`` goes straight to ``decide``, so a gate that puts the decision on a log
332
+ (examples/crewai_audit.py) decides, and refuses, exactly as this one does. The entry
333
+ for a judged call carries the fingerprint of the prepared invocation CrewAI executes;
334
+ a call refused before judgment is bound to the arguments as proposed (``_prepared``
335
+ changes the dict only when it succeeds).
336
+ """
337
+ if not isinstance(tool_input, dict):
338
+ return _unjudgeable(tool_name, "arguments are not a mapping", tool_input, record)
339
+ judged, why = _prepared(tool, tool_input)
340
+ if judged is None:
341
+ return _unjudgeable(tool_name, str(why), tool_input, record)
342
+ try:
343
+ decision, reason = decide(tool_name, judged, policy, safe_errors=True, **record)
344
+ except Exception as exc: # noqa: BLE001, a buggy policy must not silently disable the gate
345
+ return f"Recusal failed closed (policy error): RECUSAL_POLICY_ERROR ({type(exc).__name__})"
346
+ return reason if decision == "deny" else None
347
+
348
+
349
+ #: How many blocked invocations one thread may hold reasons for before the oldest is
350
+ #: dropped (a dropped entry degrades to CrewAI's generic line, never to a wrong reason).
351
+ PENDING_LIMIT = 16
352
+
353
+
354
+ class _Denials:
355
+ """The reasons the before hook blocked on, per thread, keyed by the argument object.
356
+
357
+ One entry per blocked invocation: ``(the tool_input object, reason)``. The object is
358
+ held strongly so its id cannot be reused while the entry lives. The before hook first
359
+ drops any entry for the same object, whatever the decision: one object cannot carry two
360
+ live invocations on one thread, so an entry still present is from an invocation whose
361
+ after hook never ran, and must not be attached to the next call that reuses the dict.
362
+ """
363
+
364
+ def __init__(self):
365
+ self._local = threading.local()
366
+
367
+ def _entries(self) -> list:
368
+ entries = getattr(self._local, "entries", None)
369
+ if entries is None:
370
+ entries = self._local.entries = []
371
+ return entries
372
+
373
+ def forget(self, tool_input: Any) -> None:
374
+ entries = self._entries()
375
+ entries[:] = [e for e in entries if e[0] is not tool_input]
376
+
377
+ def keep(self, tool_input: Any, reason: str) -> None:
378
+ entries = self._entries()
379
+ entries.append((tool_input, reason))
380
+ del entries[:-PENDING_LIMIT]
381
+
382
+ def take(self, tool_input: Any) -> Optional[str]:
383
+ entries = self._entries()
384
+ for index, (held, reason) in enumerate(entries):
385
+ if held is tool_input:
386
+ del entries[index]
387
+ return reason
388
+ return None
389
+
390
+
391
+ def recusal_before_tool_call(
392
+ policy: Policy,
393
+ denials: Optional[_Denials] = None,
394
+ recording: Optional[Recording] = None,
395
+ ):
396
+ """Return a ``before_tool_call`` hook: ``False`` (block) on a refusal, ``None`` to run.
397
+
398
+ ``denials`` is shared with the matching after hook (``install_recusal_hooks`` builds the
399
+ pair); without it the reason is not kept and the after hook keeps CrewAI's line.
400
+ ``recording`` puts every decision on an audit log; a ``recording`` that raises blocks
401
+ the call, like any other failure inside the hook.
402
+ """
403
+ store = denials
404
+
405
+ def gate(context: ToolCallHookContext) -> Optional[bool]:
406
+ try:
407
+ tool_input = context.tool_input
408
+ if store is not None:
409
+ try:
410
+ store.forget(tool_input)
411
+ except Exception: # noqa: BLE001, bookkeeping never changes the decision
412
+ pass
413
+ record = dict(recording(context)) if recording is not None else {}
414
+ reason = _refusal(
415
+ getattr(context, "tool", None), context.tool_name, tool_input, policy, **record
416
+ )
417
+ if reason is None:
418
+ return None
419
+ if store is not None:
420
+ try:
421
+ store.keep(tool_input, reason)
422
+ except Exception: # noqa: BLE001, a failure to keep the reason cannot unblock
423
+ pass
424
+ return False
425
+ except Exception: # noqa: BLE001, CrewAI swallows a hook's exception and runs the tool
426
+ return False
427
+
428
+ gate.recusal_gate = True # type: ignore[attr-defined]
429
+ return gate
430
+
431
+
432
+ def recusal_after_tool_call(policy: Policy, denials: Optional[_Denials] = None):
433
+ """Return an ``after_tool_call`` hook that puts the before hook's reason in a blocked
434
+ call's answer.
435
+
436
+ The reason is the one kept for this invocation's argument object; the call is never
437
+ judged again. A call another hook blocked, or one whose reason was not kept (another
438
+ thread, a dropped entry, no shared ``denials``), keeps CrewAI's own line.
439
+ """
440
+ store = denials
441
+
442
+ def explain(context: ToolCallHookContext) -> Optional[str]:
443
+ if not str(context.tool_result).startswith(BLOCKED_PREFIX):
444
+ return None
445
+ if store is None:
446
+ return None
447
+ try:
448
+ return store.take(context.tool_input)
449
+ except Exception: # noqa: BLE001, the generic line is the safe fallback
450
+ return None
451
+
452
+ explain.recusal_gate = True # type: ignore[attr-defined]
453
+ return explain
454
+
455
+
456
+ def install_recusal_hooks(
457
+ policy: Policy, recording: Optional[Recording] = None
458
+ ) -> Callable[[], None]:
459
+ """Register the gate's two hooks for every tool call in this process; return ``uninstall``.
460
+
461
+ Register it after your other hooks: hooks run in registration order, and a hook that
462
+ runs after the gate can change the arguments the gate judged. ``recording`` puts every
463
+ decision on an audit log (examples/crewai_audit.py).
464
+ """
465
+ denials = _Denials()
466
+ before = recusal_before_tool_call(policy, denials, recording)
467
+ after = recusal_after_tool_call(policy, denials)
468
+ register_before_tool_call_hook(before)
469
+ register_after_tool_call_hook(after)
470
+
471
+ def uninstall() -> None:
472
+ unregister_before_tool_call_hook(before)
473
+ unregister_after_tool_call_hook(after)
474
+
475
+ return uninstall
File without changes
@@ -0,0 +1 @@
1
+ crewai==1.15.34