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.
- recusal_crewai-0.1.0/.gitignore +44 -0
- recusal_crewai-0.1.0/LICENSE +201 -0
- recusal_crewai-0.1.0/PKG-INFO +126 -0
- recusal_crewai-0.1.0/README.md +100 -0
- recusal_crewai-0.1.0/pyproject.toml +54 -0
- recusal_crewai-0.1.0/smoke_test.py +165 -0
- recusal_crewai-0.1.0/src/recusal_crewai/__init__.py +43 -0
- recusal_crewai-0.1.0/src/recusal_crewai/_audit.py +73 -0
- recusal_crewai-0.1.0/src/recusal_crewai/_gate.py +475 -0
- recusal_crewai-0.1.0/src/recusal_crewai/py.typed +0 -0
- recusal_crewai-0.1.0/tested-with.txt +1 -0
|
@@ -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
|