Skip to content

feat: add CCS security integration for CrewAI tool execution - #6820

Open
Correctover wants to merge 5 commits into
crewAIInc:mainfrom
Correctover:ccs-security-integration
Open

feat: add CCS security integration for CrewAI tool execution#6820
Correctover wants to merge 5 commits into
crewAIInc:mainfrom
Correctover:ccs-security-integration

Conversation

@Correctover

Copy link
Copy Markdown

Summary

Integrate CCS (Cross-framework Command Security) verification into CrewAI's tool calling pipeline.

What is CCS?

CCS provides sub-millisecond in-process security checks (~7.5μs P50) for AI Agent tool execution. It uses semantic analysis to detect:

  • Remote Code Execution (RCE) patterns
  • SSRF attempts (cloud metadata, internal services)
  • Credential/secret exfiltration

Changes

  • examples/ccs-security/ccs_guard.pyCCSSecurityMiddleware with wrap_tool() and verify_tool_call()
  • examples/ccs-security/README.md — Integration guide

Usage

from examples.ccs_security.ccs_guard import CCSSecurityMiddleware
secured_tools = [CCSSecurityMiddleware.wrap_tool(t) for t in agent_tools]

References

Remove ToolCallHookContext and canonical_json from imports and __all__.
These symbols are not defined in guardrail_provider.py and cause ImportError.
…results)

- All 4 providers now pass expires_at to compute_decision_id (anti-tamper)
- Docstring example fixed to match
- CKGGuardrailProvider results: dict→list[tuple] to prevent overwrite
- make_guardrail_hook: wrapper fn instead of bound method attr assignment
- Brand attribution already present (CCS/Correctover)
…deRabbit)

- Replace ctx.trail._decisions with ctx.trail.all_decisions()[0]
- Tests should use public API, not implementation details
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds a runtime guardrail framework with authorization providers, integrity checks, audit trails, hooks, and configuration scanning. It also adds a CCS Security middleware example that verifies and wraps CrewAI tool calls.

Changes

Runtime guardrail system

Layer / File(s) Summary
Decision contracts and hashing
src/crewai/guardrails/guardrail_provider.py, tests/guardrails/test_guardrail_provider.py
Adds immutable decisions and action envelopes with expiry, integrity validation, deterministic SHA-256 identifiers, and result digests.
Authorization providers
src/crewai/guardrails/guardrail_provider.py, tests/guardrails/test_guardrail_provider.py
Adds allow-all, deny-all, allowlist, and CKG constraint providers with authorization tests and provider extensibility tests.
Execution context and audit
src/crewai/guardrails/guardrail_provider.py, tests/guardrails/test_guardrail_provider.py
Adds decision and envelope storage, per-session authorization contexts, denial callbacks, hooks, end-to-end scenarios, and audit-chain tests.
Configuration checks and public exports
src/crewai/guardrails/guardrail_provider.py, src/crewai/guardrails/__init__.py, tests/guardrails/test_guardrail_provider.py
Adds missing-provider detection and exports the guardrail models, providers, audit utilities, hooks, hashing functions, and scanning API.

CCS Security example

Layer / File(s) Summary
CCS verifier setup
examples/ccs-security/ccs_guard.py
Adds optional CCS detection and lazy verifier initialization with RCE, SSRF, and credential-leak rules.
Tool verification and wrapping
examples/ccs-security/ccs_guard.py, examples/ccs-security/README.md
Adds tool-call verification, denial handling, run interception, fallback behavior when CCS is unavailable, and CrewAI setup documentation.

Sequence Diagram(s)

sequenceDiagram
  participant ToolCallHook
  participant GuardrailContext
  participant GuardrailProvider
  participant AuditTrail
  ToolCallHook->>GuardrailContext: authorize tool call
  GuardrailContext->>GuardrailProvider: authorize(context)
  GuardrailProvider-->>GuardrailContext: GuardrailDecisionV1
  GuardrailContext->>AuditTrail: record decision
  GuardrailContext-->>ToolCallHook: allow or block result
Loading
sequenceDiagram
  participant CrewAITool
  participant CCSSecurityMiddleware
  participant CCSVerifier
  CrewAITool->>CCSSecurityMiddleware: run(arguments)
  CCSSecurityMiddleware->>CCSVerifier: verify command
  CCSVerifier-->>CCSSecurityMiddleware: allow or deny with reason
  CCSSecurityMiddleware-->>CrewAITool: block or call original run
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary CCS security integration for CrewAI tool execution.
Description check ✅ Passed The description accurately explains the CCS integration, affected example files, usage, and security checks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Warning

⚠️ This pull request has been flagged as potential spam (promotional) by CodeRabbit slop detection and should be reviewed carefully.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (6)
src/crewai/guardrails/guardrail_provider.py (2)

489-511: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the commented-out after_tool_call block.

The block is dead code. It also holds the only usage of ActionEnvelopeV1 and digest_result, so both are unused production surface right now. Delete the block and open a follow-up issue, or implement and register after_tool_call with tests.

Do you want me to open an issue to track post-execution envelope capture?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/crewai/guardrails/guardrail_provider.py` around lines 489 - 511, Remove
the commented-out after_tool_call block from GuardrailProvider, including its
references to ActionEnvelopeV1 and digest_result; do not implement or register
the future hook as part of this change.

306-323: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Align the CKG constraint documentation with the implemented contract.

Three documentation statements do not match the code.

  1. Line 311 shows CKGGuardrailProvider(constraints=[_no_shell_exec]). authorize unpacks each entry with for predicate_name, params in self._constraints, so a bare callable fails. Constraints must be (name, params_dict) tuples.
  2. Line 302 describes predicate(context, **params) as a caller-supplied callable. _eval_predicate resolves builtin names only and raises ValueError for anything else.
  3. _eval_predicate line 366 states that param_matches matches a regex or a value; the implementation only compares equality. Line 370 states that custom callables can be registered during init; no such path exists.

Update the docstrings, or add real callable support. As per coding guidelines: "Document public APIs and complex logic in Python code."

♻️ Proposed docstring fix
-    Each constraint is a (predicate, params) tuple evaluated as:
-      predicate(context, **params) -> bool  (True = constraint satisfied)
+    Each constraint is a (predicate_name, params) tuple. predicate_name must
+    name a built-in predicate (see _eval_predicate); params is a dict of
+    keyword arguments for that predicate. True means the constraint holds.
 
     A call is authorized iff ALL constraints are satisfied.
 
     Example::
 
-        def _no_shell_exec(ctx, **_):
-            return ctx.tool_name != "run_shell_command"
-
-        guardrail = CKGGuardrailProvider(constraints=[_no_shell_exec])
+        guardrail = CKGGuardrailProvider(
+            constraints=[("tool_name_not_in", {"names": ["run_shell_command"]})]
+        )
     """
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/crewai/guardrails/guardrail_provider.py` around lines 306 - 323, Update
the CKGGuardrailProvider docstrings to match the implemented contract: document
constraints as (predicate_name, params_dict) tuples, describe predicate
resolution as builtin-name-only rather than caller-supplied callables, state
that param_matches uses equality comparison only, and remove the claim that
custom callables can be registered during initialization. Keep the
implementation unchanged unless needed to ensure the documentation accurately
reflects __init__, add_constraint, authorize, and _eval_predicate.

Source: Coding guidelines

tests/guardrails/test_guardrail_provider.py (3)

711-728: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename the test and delete the unused locals.

The name test_compute_decision_id_determinism_across_providers promises equal IDs, but the test asserts that the IDs differ. claims at line 713 and expires_at at line 714 are never used. Rename to something like test_decision_id_binds_to_provider_specific_claims, and remove the two unused locals. Add a separate test that calls compute_decision_id twice with the same claims and the same expires_at to cover determinism directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/guardrails/test_guardrail_provider.py` around lines 711 - 728, Rename
test_compute_decision_id_determinism_across_providers to reflect that
provider-specific claims produce distinct decision IDs, and remove its unused
claims and expires_at locals. Add a separate test that invokes
compute_decision_id twice with identical claims and expires_at values and
asserts the results are equal.

126-137: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert behavior instead of attribute absence.

assert not hasattr(env, "tool_result") locks the test to the current field layout. The behavior under test is that the envelope stores a digest and never the raw result. Assert that tool_result_digest equals digest_result(raw_value) for a known raw value, and that the raw value does not appear in the envelope fields.

As per coding guidelines: "Write unit tests for new functionality that focus on behavior rather than implementation details."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/guardrails/test_guardrail_provider.py` around lines 126 - 137, Update
test_never_stores_raw_result to derive tool_result_digest by applying
digest_result to a known raw value, then assert the envelope stores that digest
and does not contain the raw value among its fields. Remove the hasattr(env,
"tool_result") assertion so the test verifies behavior rather than the current
attribute layout.

Source: Coding guidelines


214-227: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Constrain the factory context to the actual hook context.

_make_context returns a bare MagicMock, so missing ToolCallHookContext fields would not fail tests. If you need this factory, use MagicMock(spec=ToolCallHookContext) or a small frozen dataclass stub with the same fields.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/guardrails/test_guardrail_provider.py` around lines 214 - 227, Update
the _make_context test factory to create a constrained
ToolCallHookContext-shaped object instead of a bare MagicMock. Prefer
MagicMock(spec=ToolCallHookContext), or use a frozen dataclass stub containing
the required context fields, while preserving the existing tool_name,
tool_input, agent, task, and crew setup.
src/crewai/guardrails/__init__.py (1)

14-42: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the package exports match the module's public surface.

guardrail_provider.__all__ lists make_decision_id and digest_result, but the package does not re-export them. digest_result is required by any consumer that builds an ActionEnvelopeV1, and the package does export ActionEnvelopeV1. Add both names, or remove them from the module's __all__.

Also remove the stray comment on line 45. Commit notes belong in the commit message, not in the package initializer.

♻️ Proposed fix
     make_guardrail_hook,
     detect_missing_guardrail,
     compute_decision_id,
+    make_decision_id,
+    digest_result,
 )
 
 __all__ = [
     "detect_missing_guardrail",
     "compute_decision_id",
+    "make_decision_id",
+    "digest_result",
 ]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/crewai/guardrails/__init__.py` around lines 14 - 42, Update the guardrail
package exports in the __init__.py import list and __all__ to match
guardrail_provider.__all__: re-export make_decision_id and digest_result
alongside ActionEnvelopeV1, or remove those names from the module’s public
surface consistently. Also remove the stray comment near the end of the package
initializer.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/ccs-security/ccs_guard.py`:
- Around line 6-10: Correct the documented import path in
examples/ccs-security/ccs_guard.py lines 6-10 by using the supported packaged or
renamed module path, or documenting the required copy/install method; apply the
identical correction in examples/ccs-security/README.md lines 7-12 so both
locations consistently describe an importable setup.
- Around line 62-64: Update verify_tool_call() and the secured_run() wrapping
flow so an unavailable ccs_verifier cannot permit original_run() to execute.
Require a successfully initialized verifier when creating the wrapper, or return
a denial result and prevent tool execution whenever get_verifier() returns None;
preserve normal verification behavior when the verifier is available.

In `@src/crewai/guardrails/guardrail_provider.py`:
- Around line 396-411: The AuditTrail currently overwrites repeated identical
records because it keys storage only by decision_id. In
src/crewai/guardrails/guardrail_provider.py lines 396-411, update AuditTrail to
append decisions and envelopes to ordered lists while retaining the dicts only
as lookup indexes; have all_decisions, all_envelopes, total_decisions, and
total_envelopes use those lists, clear both structures in clear(), and correct
the docstring’s thread-safety claim. In
tests/guardrails/test_guardrail_provider.py lines 697-709, preserve the
total_decisions == len(calls) and granted == 3 assertions and add coverage
proving two identical consecutive calls produce two recorded decisions.
- Around line 556-562: Update the tool-name extraction in the guardrail scanner
around _has_guardrail_provider so non-string tool entries are handled safely,
including BaseTool instances, without calling mapping-only .get on arbitrary
objects. Resolve the tool name via the object’s supported name attribute or
established tool interface, retain dictionary handling, and continue recording
tool:name entries for tools lacking a GuardrailProvider.
- Around line 326-352: Update the denial-reason comprehension in
CKGGuardrailProvider’s authorize flow to iterate directly over the results list
rather than calling results.items(). Preserve the existing successful reason and
ensure failed predicates are collected from each (name, status) tuple so denied
authorization returns normally.
- Around line 57-61: Update is_expired to use the provided now value whenever it
is not None, including 0 and 0.0; only call time.time() when now is explicitly
None, while preserving the existing expires_at check and comparison.
- Around line 247-273: Update ToolListGuardrailProvider.authorize so
default_block=False still permits tools in _allowed and only changes the
treatment of tools outside the allowlist; align the constructor documentation,
parameter semantics, and test_inverted_mode with this allowlist contract.
Preserve the existing default_block=True behavior for listed and unlisted tools.
- Around line 127-133: Update make_decision_id to accept and forward the
decision’s expires_at value to compute_decision_id so IDs generated for expiring
decisions are accepted by verify_integrity; alternatively remove the helper if
it is not intended to support that contract. If retained, export
make_decision_id from the package initializer and add coverage for non-None
expires_at integrity verification.

---

Nitpick comments:
In `@src/crewai/guardrails/__init__.py`:
- Around line 14-42: Update the guardrail package exports in the __init__.py
import list and __all__ to match guardrail_provider.__all__: re-export
make_decision_id and digest_result alongside ActionEnvelopeV1, or remove those
names from the module’s public surface consistently. Also remove the stray
comment near the end of the package initializer.

In `@src/crewai/guardrails/guardrail_provider.py`:
- Around line 489-511: Remove the commented-out after_tool_call block from
GuardrailProvider, including its references to ActionEnvelopeV1 and
digest_result; do not implement or register the future hook as part of this
change.
- Around line 306-323: Update the CKGGuardrailProvider docstrings to match the
implemented contract: document constraints as (predicate_name, params_dict)
tuples, describe predicate resolution as builtin-name-only rather than
caller-supplied callables, state that param_matches uses equality comparison
only, and remove the claim that custom callables can be registered during
initialization. Keep the implementation unchanged unless needed to ensure the
documentation accurately reflects __init__, add_constraint, authorize, and
_eval_predicate.

In `@tests/guardrails/test_guardrail_provider.py`:
- Around line 711-728: Rename
test_compute_decision_id_determinism_across_providers to reflect that
provider-specific claims produce distinct decision IDs, and remove its unused
claims and expires_at locals. Add a separate test that invokes
compute_decision_id twice with identical claims and expires_at values and
asserts the results are equal.
- Around line 126-137: Update test_never_stores_raw_result to derive
tool_result_digest by applying digest_result to a known raw value, then assert
the envelope stores that digest and does not contain the raw value among its
fields. Remove the hasattr(env, "tool_result") assertion so the test verifies
behavior rather than the current attribute layout.
- Around line 214-227: Update the _make_context test factory to create a
constrained ToolCallHookContext-shaped object instead of a bare MagicMock.
Prefer MagicMock(spec=ToolCallHookContext), or use a frozen dataclass stub
containing the required context fields, while preserving the existing tool_name,
tool_input, agent, task, and crew setup.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 789472c7-df8f-47b7-81bb-f1e818b99c77

📥 Commits

Reviewing files that changed from the base of the PR and between b5bf858 and 6eb1f2e.

📒 Files selected for processing (5)
  • examples/ccs-security/README.md
  • examples/ccs-security/ccs_guard.py
  • src/crewai/guardrails/__init__.py
  • src/crewai/guardrails/guardrail_provider.py
  • tests/guardrails/test_guardrail_provider.py

Comment on lines +6 to +10
Usage:
from examples.ccs_security.ccs_guard import CCSSecurityMiddleware

# Wrap any CrewAI tool with CCS verification
secured_tool = CCSSecurityMiddleware.wrap_tool(my_tool)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Correct the documented Python module path.

examples.ccs_security.ccs_guard does not map to examples/ccs-security/ccs_guard.py. Python cannot import a module from a directory named ccs-security.

  • examples/ccs-security/ccs_guard.py#L6-L10: use the actual import path after renaming and packaging the example directory, or document the supported copy/install method.
  • examples/ccs-security/README.md#L7-L12: use the same corrected import path.
📍 Affects 2 files
  • examples/ccs-security/ccs_guard.py#L6-L10 (this comment)
  • examples/ccs-security/README.md#L7-L12
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/ccs-security/ccs_guard.py` around lines 6 - 10, Correct the
documented import path in examples/ccs-security/ccs_guard.py lines 6-10 by using
the supported packaged or renamed module path, or documenting the required
copy/install method; apply the identical correction in
examples/ccs-security/README.md lines 7-12 so both locations consistently
describe an importable setup.

Comment on lines +62 to +64
verifier = cls.get_verifier()
if verifier is None:
return True, "CCS not available"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate ccs_guard.py =="
fd -a 'ccs_guard\.py$' . || true

echo "== Outline ccs_guard.py =="
ast-grep outline examples/ccs-security/ccs_guard.py --view expanded || true

echo "== Relevant ccs_guard.py lines =="
cat -n examples/ccs-security/ccs_guard.py | sed -n '1,140p'

echo "== Search verifier availability references =="
rg -n "ccs_verifier|get_verifier|secured_run|secured_tools|CCS not available" examples/ccs-security lib .github README* 2>/dev/null || true

echo "== BaseTool run snippet =="
cat -n lib/crewai/src/crewai/tools/base_tool.py | sed -n '300,340p'

Repository: crewAIInc/crewAI

Length of output: 7066


Security Misconfiguration (CWE-693)

Reachability: External

Fail closed when CCS is unavailable.

When ccs_verifier cannot be imported or initialized, verify_tool_call() returns (True, "CCS not available"), and secured_run() calls original_run() without verification. Require the verifier during wrapping, or deny tool execution while CCS is unavailable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/ccs-security/ccs_guard.py` around lines 62 - 64, Update
verify_tool_call() and the secured_run() wrapping flow so an unavailable
ccs_verifier cannot permit original_run() to execute. Require a successfully
initialized verifier when creating the wrapper, or return a denial result and
prevent tool execution whenever get_verifier() returns None; preserve normal
verification behavior when the verifier is available.

Comment on lines +57 to +61
def is_expired(self, now: float | None = None) -> bool:
"""Check whether this decision has expired."""
if self.expires_at is None:
return False
return (now or time.time()) > self.expires_at

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use an explicit None check for now.

(now or time.time()) treats now=0 and now=0.0 as "not provided". A caller that passes an epoch-0 timestamp gets the wall clock instead. Compare against None.

🐛 Proposed fix
     def is_expired(self, now: float | None = None) -> bool:
         """Check whether this decision has expired."""
         if self.expires_at is None:
             return False
-        return (now or time.time()) > self.expires_at
+        current = time.time() if now is None else now
+        return current > self.expires_at
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def is_expired(self, now: float | None = None) -> bool:
"""Check whether this decision has expired."""
if self.expires_at is None:
return False
return (now or time.time()) > self.expires_at
def is_expired(self, now: float | None = None) -> bool:
"""Check whether this decision has expired."""
if self.expires_at is None:
return False
current = time.time() if now is None else now
return current > self.expires_at
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/crewai/guardrails/guardrail_provider.py` around lines 57 - 61, Update
is_expired to use the provided now value whenever it is not None, including 0
and 0.0; only call time.time() when now is explicitly None, while preserving the
existing expires_at check and comparison.

Comment on lines +127 to +133
def make_decision_id(namespace: str, payload: dict[str, Any]) -> str:
"""Convenience: hash an identifier namespace into the computation.

Namespacing prevents decision_id collisions across different tools
or agents within the same crew.
"""
return compute_decision_id({"namespace": namespace, **payload})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

make_decision_id cannot produce an ID that verify_integrity accepts.

make_decision_id calls compute_decision_id without expires_at. A decision built with decision_id=make_decision_id(...) and a non-None expires_at always fails verify_integrity(). The function is also unexported from the package initializer and untested. Either forward expires_at or remove the helper.

♻️ Proposed fix
-def make_decision_id(namespace: str, payload: dict[str, Any]) -> str:
+def make_decision_id(
+    namespace: str,
+    payload: dict[str, Any],
+    expires_at: float | None = None,
+) -> str:
     """Convenience: hash an identifier namespace into the computation.
 
     Namespacing prevents decision_id collisions across different tools
     or agents within the same crew.
     """
-    return compute_decision_id({"namespace": namespace, **payload})
+    return compute_decision_id({"namespace": namespace, **payload}, expires_at)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def make_decision_id(namespace: str, payload: dict[str, Any]) -> str:
"""Convenience: hash an identifier namespace into the computation.
Namespacing prevents decision_id collisions across different tools
or agents within the same crew.
"""
return compute_decision_id({"namespace": namespace, **payload})
def make_decision_id(
namespace: str,
payload: dict[str, Any],
expires_at: float | None = None,
) -> str:
"""Convenience: hash an identifier namespace into the computation.
Namespacing prevents decision_id collisions across different tools
or agents within the same crew.
"""
return compute_decision_id({"namespace": namespace, **payload}, expires_at)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/crewai/guardrails/guardrail_provider.py` around lines 127 - 133, Update
make_decision_id to accept and forward the decision’s expires_at value to
compute_decision_id so IDs generated for expiring decisions are accepted by
verify_integrity; alternatively remove the helper if it is not intended to
support that contract. If retained, export make_decision_id from the package
initializer and add coverage for non-None expires_at integrity verification.

Comment on lines +247 to +273
class ToolListGuardrailProvider(GuardrailProvider):
"""Allows only an explicit allowlist of tool names.

Args:
allowed_tools: Set of tool names permitted for execution.
default_block: Whether to block (True) or allow (False) tools
not in the allowlist.

Example::

guardrail = ToolListGuardrailProvider(
allowed_tools={"read_file", "search_web", "calculator"},
default_block=True,
)
"""

def __init__(
self,
allowed_tools: set[str],
default_block: bool = True,
) -> None:
self._allowed = frozenset(allowed_tools)
self._default_block = default_block

def authorize(self, context: ToolCallHookContext) -> GuardrailDecisionV1:
is_allowed = context.tool_name in self._allowed
authorized = is_allowed if self._default_block else (not is_allowed)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

default_block=False contradicts the documented and named semantics.

The docstring states that default_block=False allows tools that are not in the allowlist. Line 273 instead inverts the decision, so allowed_tools becomes a denylist and every listed tool is blocked. A user who relaxes the guardrail with default_block=False blocks the tools they explicitly allowed.

Pick one contract and make the code, the docstring, the parameter name, and test_inverted_mode in tests/guardrails/test_guardrail_provider.py agree. If denylist mode is the intent, rename the constructor arguments (for example tools plus mode="allowlist" | "denylist"). If the docstring is the intent, apply this fix.

🐛 Proposed fix for allowlist semantics
     def authorize(self, context: ToolCallHookContext) -> GuardrailDecisionV1:
         is_allowed = context.tool_name in self._allowed
-        authorized = is_allowed if self._default_block else (not is_allowed)
+        # default_block=False means unlisted tools are permitted too.
+        authorized = is_allowed or not self._default_block
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class ToolListGuardrailProvider(GuardrailProvider):
"""Allows only an explicit allowlist of tool names.
Args:
allowed_tools: Set of tool names permitted for execution.
default_block: Whether to block (True) or allow (False) tools
not in the allowlist.
Example::
guardrail = ToolListGuardrailProvider(
allowed_tools={"read_file", "search_web", "calculator"},
default_block=True,
)
"""
def __init__(
self,
allowed_tools: set[str],
default_block: bool = True,
) -> None:
self._allowed = frozenset(allowed_tools)
self._default_block = default_block
def authorize(self, context: ToolCallHookContext) -> GuardrailDecisionV1:
is_allowed = context.tool_name in self._allowed
authorized = is_allowed if self._default_block else (not is_allowed)
class ToolListGuardrailProvider(GuardrailProvider):
"""Allows only an explicit allowlist of tool names.
Args:
allowed_tools: Set of tool names permitted for execution.
default_block: Whether to block (True) or allow (False) tools
not in the allowlist.
Example::
guardrail = ToolListGuardrailProvider(
allowed_tools={"read_file", "search_web", "calculator"},
default_block=True,
)
"""
def __init__(
self,
allowed_tools: set[str],
default_block: bool = True,
) -> None:
self._allowed = frozenset(allowed_tools)
self._default_block = default_block
def authorize(self, context: ToolCallHookContext) -> GuardrailDecisionV1:
is_allowed = context.tool_name in self._allowed
# default_block=False means unlisted tools are permitted too.
authorized = is_allowed or not self._default_block
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/crewai/guardrails/guardrail_provider.py` around lines 247 - 273, Update
ToolListGuardrailProvider.authorize so default_block=False still permits tools
in _allowed and only changes the treatment of tools outside the allowlist; align
the constructor documentation, parameter semantics, and test_inverted_mode with
this allowlist contract. Preserve the existing default_block=True behavior for
listed and unlisted tools.

Comment on lines +326 to +352
results: list[tuple[str, bool]] = []
all_satisfied = True

for predicate_name, params in self._constraints:
ok = self._eval_predicate(predicate_name, context, **params)
results.append((predicate_name, ok))
if not ok:
all_satisfied = False

claims = {
"tool": context.tool_name,
"agent": getattr(context.agent, "role", "unknown"),
"constraint_results": results,
}
expires_at = time.time() + 60
return GuardrailDecisionV1(
decision_id=compute_decision_id(claims, expires_at),
authorized=all_satisfied,
claims=claims,
expires_at=expires_at,
reason=(
"All constraints satisfied"
if all_satisfied
else f"Constraints failed: {[k for k, v in results.items() if not v]}"
),
provider_name="ckg",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check how before-tool-call hook exceptions are handled by the dispatcher.
set -euo pipefail

fd -t f 'tool_hooks.py'
rg -n -C 8 'before_tool_call' --glob '*.py' -g '!tests/**'

Repository: crewAIInc/crewAI

Length of output: 238


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== tool_hooks.py outline/sections ==="
wc -l lib/crewai/src/crewai/hooks/tool_hooks.py
sed -n '1,260p' lib/crewai/src/crewai/hooks/tool_hooks.py

echo "=== guardrail_provider relevant sections ==="
fd -t f 'guardrail_provider.py' | xargs -r -I{} sh -c 'echo "--- {}"; wc -l "{}"; sed -n "260,370p" "{}"'

echo "=== search guardrail provider uses ==="
rg -n 'CKGGuardrailProvider|guardrail_provider|before_tool_call|authorize\(' --glob '*.py' -g '!tests/**'

Repository: crewAIInc/crewAI

Length of output: 18397


Fix the denial reason before returning it.

results is a list, so results.items() raises AttributeError whenever any CKG constraint evaluates to false. Build the failed predicate list from the list contents, e.g. [k for k, v in results if not v], so CKGGuardrailProvider.authorize() returns authorized=False instead of raising on the denial path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/crewai/guardrails/guardrail_provider.py` around lines 326 - 352, Update
the denial-reason comprehension in CKGGuardrailProvider’s authorize flow to
iterate directly over the results list rather than calling results.items().
Preserve the existing successful reason and ensure failed predicates are
collected from each (name, status) tuple so denied authorization returns
normally.

Comment on lines +396 to +411
class AuditTrail:
"""In-memory audit trail that records every decision + envelope pair.

Thread-safe for single-threaded crew execution. For production,
swap the backend for a durable store (SQLite, Redis, etc.).
"""

def __init__(self) -> None:
self._decisions: dict[str, GuardrailDecisionV1] = {}
self._envelopes: dict[str, ActionEnvelopeV1] = {}

def record_decision(self, decision: GuardrailDecisionV1) -> None:
self._decisions[decision.decision_id] = decision

def record_envelope(self, envelope: ActionEnvelopeV1) -> None:
self._envelopes[envelope.decision_id] = envelope

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

AuditTrail keys records by content address, so repeated identical tool calls overwrite each other. decision_id is a SHA-256 of claims plus expires_at. Two identical calls inside one time.time() tick produce one key, so the trail silently drops evidence and under-counts calls. The test that repeats read_file inherits the same defect as an intermittent failure.

  • src/crewai/guardrails/guardrail_provider.py#L396-L411: append each record to an ordered list in record_decision and record_envelope, keep the dicts as lookup indexes only, and return the lists from all_decisions, all_envelopes, total_decisions, and total_envelopes. Clear both structures in clear(). Also correct the "Thread-safe for single-threaded crew execution" docstring, because no lock exists.
  • tests/guardrails/test_guardrail_provider.py#L697-L709: after the trail records every call, keep the total_decisions == len(calls) and granted == 3 assertions, and add an assertion that two identical consecutive calls yield two recorded decisions.
📍 Affects 2 files
  • src/crewai/guardrails/guardrail_provider.py#L396-L411 (this comment)
  • tests/guardrails/test_guardrail_provider.py#L697-L709
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/crewai/guardrails/guardrail_provider.py` around lines 396 - 411, The
AuditTrail currently overwrites repeated identical records because it keys
storage only by decision_id. In src/crewai/guardrails/guardrail_provider.py
lines 396-411, update AuditTrail to append decisions and envelopes to ordered
lists while retaining the dicts only as lookup indexes; have all_decisions,
all_envelopes, total_decisions, and total_envelopes use those lists, clear both
structures in clear(), and correct the docstring’s thread-safety claim. In
tests/guardrails/test_guardrail_provider.py lines 697-709, preserve the
total_decisions == len(calls) and granted == 3 assertions and add coverage
proving two identical consecutive calls produce two recorded decisions.

Comment on lines +556 to +562
missing: list[str] = []
tools = agent_config.get("tools", [])
for tool in tools if isinstance(tools, list) else []:
tool_name = tool if isinstance(tool, str) else tool.get("name", "?")
if not _has_guardrail_provider(agent_config, tool_name):
missing.append(f"tool:{tool_name} — no GuardrailProvider")
return missing

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle tool objects, not just strings and dicts.

Line 559 calls tool.get("name", "?") for any non-string entry. crewAI agents usually hold BaseTool instances. Such an object has no .get, so this scanner raises AttributeError on a real agent config.

🐛 Proposed fix
     for tool in tools if isinstance(tools, list) else []:
-        tool_name = tool if isinstance(tool, str) else tool.get("name", "?")
+        if isinstance(tool, str):
+            tool_name = tool
+        elif isinstance(tool, dict):
+            tool_name = tool.get("name", "?")
+        else:
+            tool_name = getattr(tool, "name", "?")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
missing: list[str] = []
tools = agent_config.get("tools", [])
for tool in tools if isinstance(tools, list) else []:
tool_name = tool if isinstance(tool, str) else tool.get("name", "?")
if not _has_guardrail_provider(agent_config, tool_name):
missing.append(f"tool:{tool_name} — no GuardrailProvider")
return missing
missing: list[str] = []
tools = agent_config.get("tools", [])
for tool in tools if isinstance(tools, list) else []:
if isinstance(tool, str):
tool_name = tool
elif isinstance(tool, dict):
tool_name = tool.get("name", "?")
else:
tool_name = getattr(tool, "name", "?")
if not _has_guardrail_provider(agent_config, tool_name):
missing.append(f"tool:{tool_name} — no GuardrailProvider")
return missing
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/crewai/guardrails/guardrail_provider.py` around lines 556 - 562, Update
the tool-name extraction in the guardrail scanner around _has_guardrail_provider
so non-string tool entries are handled safely, including BaseTool instances,
without calling mapping-only .get on arbitrary objects. Resolve the tool name
via the object’s supported name attribute or established tool interface, retain
dictionary handling, and continue recording tool:name entries for tools lacking
a GuardrailProvider.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant