Compare commits

...

3 Commits

Author SHA1 Message Date
Lorenze Jay
c091aa63c6 Merge branch 'main' into fix/oss-9-extra-forbidden-security-context 2026-04-02 09:10:49 -07:00
Greyson LaLonde
9e51229e6c chore: add ExecutionContext model for state 2026-04-02 23:44:21 +08:00
Iris Clawd
edd79e50ef fix: ignore extra fields in dynamic tool schemas to prevent security_context validation errors
Changes the default Pydantic config in create_model_from_schema() from
extra='forbid' to extra='ignore'. This fixes OSS-9 where the framework
injects security_context metadata into tool call arguments, but MCP tools
and integration tools (created via create_model_from_schema) reject any
extra fields with Pydantic's extra_forbidden error.

Affected tools: all MCP tools (MCPServerAdapter, MCPToolResolver) and
all platform integration tools (CrewAIPlatformActionTool) — these all
use create_model_from_schema() without a custom __config__, so they
inherited the extra='forbid' default.

Regular user-defined tools (subclassing BaseModel) were not affected
because BaseModel defaults to extra='ignore'.

The fix is backward-compatible:
- Required fields are still enforced
- Type validation is still enforced
- Callers can still opt into extra='forbid' via __config__ parameter
- Tools that define security_context in their schema still receive it

Fixes: OSS-9
Related: #4796, #4841
2026-04-01 19:42:13 +00:00
5 changed files with 230 additions and 2 deletions

View File

@@ -10,6 +10,7 @@ from crewai.agent.core import Agent
from crewai.agent.planning_config import PlanningConfig
from crewai.crew import Crew
from crewai.crews.crew_output import CrewOutput
from crewai.execution_context import ExecutionContext
from crewai.flow.flow import Flow
from crewai.knowledge.knowledge import Knowledge
from crewai.llm import LLM
@@ -178,6 +179,7 @@ __all__ = [
"BaseLLM",
"Crew",
"CrewOutput",
"ExecutionContext",
"Flow",
"Knowledge",
"LLMGuardrail",

View File

@@ -25,13 +25,25 @@ def _get_or_create_counter() -> Iterator[int]:
return counter
_last_emitted: contextvars.ContextVar[int] = contextvars.ContextVar(
"_last_emitted", default=0
)
def get_next_emission_sequence() -> int:
"""Get the next emission sequence number.
Returns:
The next sequence number.
"""
return next(_get_or_create_counter())
seq = next(_get_or_create_counter())
_last_emitted.set(seq)
return seq
def get_emission_sequence() -> int:
"""Get the current emission sequence value without incrementing."""
return _last_emitted.get()
def reset_emission_counter() -> None:
@@ -41,6 +53,14 @@ def reset_emission_counter() -> None:
"""
counter: Iterator[int] = itertools.count(start=1)
_emission_counter.set(counter)
_last_emitted.set(0)
def set_emission_counter(start: int) -> None:
"""Set the emission counter to resume from a given value."""
counter: Iterator[int] = itertools.count(start=start + 1)
_emission_counter.set(counter)
_last_emitted.set(start)
class BaseEvent(BaseModel):

View File

@@ -0,0 +1,80 @@
"""Checkpointable execution context for the crewAI runtime.
Captures the ContextVar state needed to resume execution from a checkpoint.
Used by the RootModel (step 5) to include execution context in snapshots.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
from crewai.context import (
_current_task_id,
_platform_integration_token,
)
from crewai.events.base_events import (
get_emission_sequence,
set_emission_counter,
)
from crewai.events.event_context import (
_event_id_stack,
_last_event_id,
_triggering_event_id,
)
from crewai.flow.flow_context import (
current_flow_id,
current_flow_method_name,
current_flow_request_id,
)
class ExecutionContext(BaseModel):
"""Snapshot of ContextVar state required for checkpoint/resume."""
current_task_id: str | None = Field(default=None)
flow_request_id: str | None = Field(default=None)
flow_id: str | None = Field(default=None)
flow_method_name: str = Field(default="unknown")
event_id_stack: tuple[tuple[str, str], ...] = Field(default=())
last_event_id: str | None = Field(default=None)
triggering_event_id: str | None = Field(default=None)
emission_sequence: int = Field(default=0)
feedback_callback_info: dict[str, Any] | None = Field(default=None)
platform_token: str | None = Field(default=None)
def capture_execution_context(
feedback_callback_info: dict[str, Any] | None = None,
) -> ExecutionContext:
"""Read all checkpoint-required ContextVars into an ExecutionContext."""
return ExecutionContext(
current_task_id=_current_task_id.get(),
flow_request_id=current_flow_request_id.get(),
flow_id=current_flow_id.get(),
flow_method_name=current_flow_method_name.get(),
event_id_stack=_event_id_stack.get(),
last_event_id=_last_event_id.get(),
triggering_event_id=_triggering_event_id.get(),
emission_sequence=get_emission_sequence(),
feedback_callback_info=feedback_callback_info,
platform_token=_platform_integration_token.get(),
)
def apply_execution_context(ctx: ExecutionContext) -> None:
"""Write an ExecutionContext back into the ContextVars."""
_current_task_id.set(ctx.current_task_id)
current_flow_request_id.set(ctx.flow_request_id)
current_flow_id.set(ctx.flow_id)
current_flow_method_name.set(ctx.flow_method_name)
_event_id_stack.set(ctx.event_id_stack)
_last_event_id.set(ctx.last_event_id)
_triggering_event_id.set(ctx.triggering_event_id)
set_emission_counter(ctx.emission_sequence)
_platform_integration_token.set(ctx.platform_token)

View File

@@ -623,7 +623,7 @@ def create_model_from_schema( # type: ignore[no-any-unimported]
for name, prop in (json_schema.get("properties", {}) or {}).items()
}
effective_config = __config__ or ConfigDict(extra="forbid")
effective_config = __config__ or ConfigDict(extra="ignore")
return create_model_base(
effective_name,

View File

@@ -882,3 +882,129 @@ class TestEndToEndMCPSchema:
)
assert obj.filters.date_from == datetime.date(2025, 1, 1)
assert obj.filters.categories == ["news", "tech"]
class TestExtraFieldsIgnored:
"""Regression tests for OSS-9: security_context injection causing
extra_forbidden errors on MCP and integration tool schemas.
When the framework injects metadata like security_context into tool call
arguments, dynamically-created Pydantic models must ignore (not reject)
extra fields so that tool execution is not blocked.
"""
SIMPLE_TOOL_SCHEMA: dict[str, Any] = {
"type": "object",
"title": "ExecuteSqlSchema",
"properties": {
"query": {
"type": "string",
"description": "The SQL query to execute.",
},
},
"required": ["query"],
}
OUTLOOK_TOOL_SCHEMA: dict[str, Any] = {
"type": "object",
"title": "MicrosoftOutlookSendEmailSchema",
"properties": {
"to_recipients": {
"type": "array",
"items": {"type": "string"},
"description": "Array of recipient email addresses.",
},
"subject": {
"type": "string",
"description": "Email subject line.",
},
"body": {
"type": "string",
"description": "Email body content.",
},
},
"required": ["to_recipients", "subject", "body"],
}
SECURITY_CONTEXT_PAYLOAD: dict[str, Any] = {
"agent_fingerprint": {
"user_id": "test-user-123",
"session_id": "test-session-456",
"metadata": {},
},
}
def test_mcp_tool_schema_ignores_security_context(self) -> None:
"""Reproduces OSS-9 Case 1: Databricks MCP execute_sql fails when
security_context is injected into tool args."""
Model = create_model_from_schema(self.SIMPLE_TOOL_SCHEMA)
# This previously raised: Extra inputs are not permitted
# [type=extra_forbidden, input_value={'agent_fingerprint': ...}]
obj = Model.model_validate(
{
"query": "SELECT * FROM my_table",
"security_context": self.SECURITY_CONTEXT_PAYLOAD,
}
)
assert obj.query == "SELECT * FROM my_table"
# security_context should be silently dropped, not present on the model
assert not hasattr(obj, "security_context")
def test_integration_tool_schema_ignores_security_context(self) -> None:
"""Reproduces OSS-9 Case 2: Microsoft Outlook send_email fails when
security_context is injected into tool args."""
Model = create_model_from_schema(self.OUTLOOK_TOOL_SCHEMA)
obj = Model.model_validate(
{
"to_recipients": ["user@example.com"],
"subject": "Test",
"body": "Hello",
"security_context": self.SECURITY_CONTEXT_PAYLOAD,
}
)
assert obj.to_recipients == ["user@example.com"]
assert obj.subject == "Test"
assert not hasattr(obj, "security_context")
def test_arbitrary_extra_fields_ignored(self) -> None:
"""Any unexpected extra field should be silently ignored, not just
security_context."""
Model = create_model_from_schema(self.SIMPLE_TOOL_SCHEMA)
obj = Model.model_validate(
{
"query": "SELECT 1",
"some_unknown_field": "should be dropped",
"another_extra": 42,
}
)
assert obj.query == "SELECT 1"
assert not hasattr(obj, "some_unknown_field")
assert not hasattr(obj, "another_extra")
def test_required_fields_still_enforced(self) -> None:
"""Changing to extra=ignore must NOT weaken required field validation."""
Model = create_model_from_schema(self.SIMPLE_TOOL_SCHEMA)
with pytest.raises(Exception):
Model.model_validate({"security_context": self.SECURITY_CONTEXT_PAYLOAD})
def test_type_validation_still_enforced(self) -> None:
"""Changing to extra=ignore must NOT weaken type validation."""
Model = create_model_from_schema(self.SIMPLE_TOOL_SCHEMA)
with pytest.raises(Exception):
Model.model_validate({"query": 12345}) # should be string
def test_explicit_extra_forbid_still_works(self) -> None:
"""Callers can still opt into extra=forbid via __config__."""
from pydantic import ConfigDict
Model = create_model_from_schema(
self.SIMPLE_TOOL_SCHEMA,
__config__=ConfigDict(extra="forbid"),
)
with pytest.raises(Exception):
Model.model_validate(
{
"query": "SELECT 1",
"security_context": self.SECURITY_CONTEXT_PAYLOAD,
}
)