Skip to content

Commit 2656bf2

Browse files
chore: ensure messages are not modified before span serialization (#15567)
1 parent 9f4f8b8 commit 2656bf2

File tree

2 files changed

+35
-6
lines changed

2 files changed

+35
-6
lines changed

ddtrace/appsec/ai_guard/_api_client.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""AI Guard client for security evaluation of agentic AI workflows."""
22

3+
from copy import deepcopy
34
import json
45
from typing import Any
56
from typing import List
@@ -49,6 +50,7 @@ class Message(TypedDict, total=False):
4950
class Evaluation(TypedDict):
5051
action: Literal["ALLOW", "DENY", "ABORT"]
5152
reason: str
53+
tags: List[str]
5254

5355

5456
class Options(TypedDict, total=False):
@@ -125,13 +127,13 @@ def _messages_for_meta_struct(messages: List[Message]) -> List[Message]:
125127

126128
def truncate_message(message: Message) -> Message:
127129
nonlocal content_truncated
128-
content = message.get("content", "")
130+
# ensure the message cannot be modified before serialization
131+
new_message = deepcopy(message)
132+
content = new_message.get("content", "")
129133
if len(content) > max_content_size:
130-
truncated = message.copy()
131-
truncated["content"] = content[:max_content_size]
134+
new_message["content"] = content[:max_content_size]
132135
content_truncated = True
133-
return truncated
134-
return message
136+
return new_message
135137

136138
result = [truncate_message(message) for message in messages]
137139
if content_truncated:
@@ -268,7 +270,7 @@ def evaluate(self, messages: List[Message], options: Optional[Options] = None) -
268270
span.set_tag(AI_GUARD.BLOCKED_TAG, "true")
269271
raise AIGuardAbortError(action=action, reason=reason, tags=tags)
270272

271-
return Evaluation(action=action, reason=reason)
273+
return Evaluation(action=action, reason=reason, tags=tags)
272274

273275
except AIGuardAbortError:
274276
raise

tests/appsec/ai_guard/api/test_api_client.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ def test_evaluate_method(
109109
result = ai_guard_client.evaluate(messages, Options(block=blocking))
110110
assert result["action"] == action
111111
assert result["reason"] == reason
112+
if tags:
113+
assert result["tags"] == tags
112114

113115
expected_tags = {"ai_guard.target": target, "ai_guard.action": action}
114116
if target == "tool":
@@ -233,6 +235,31 @@ def test_span_meta_content_truncation(mock_execute_request, telemetry_mock, ai_g
233235
assert_telemetry(telemetry_mock, "ai_guard.truncated", (("type", "content"),))
234236

235237

238+
@patch("ddtrace.internal.telemetry.telemetry_writer._namespace")
239+
@patch("ddtrace.appsec.ai_guard._api_client.AIGuardClient._execute_request")
240+
def test_message_immutability(mock_execute_request, telemetry_mock, ai_guard_client, tracer):
241+
mock_execute_request.return_value = mock_evaluate_response("ALLOW")
242+
243+
messages = [
244+
Message(role="assistant", tool_calls=[ToolCall(id="call_1", function=Function(name="test", arguments="{}"))])
245+
]
246+
with tracer.trace("test"):
247+
ai_guard_client.evaluate(messages)
248+
# Update messages before being flushed
249+
messages[0].get("tool_calls").append(ToolCall(id="call_2", function=Function(name="test", arguments="{}")))
250+
messages.append(
251+
Message(
252+
role="assistant", tool_calls=[ToolCall(id="call_2", function=Function(name="test", arguments="{}"))]
253+
)
254+
)
255+
256+
span = tracer.get_spans()[1] # AI Guard span
257+
meta = span._get_struct_tag(AI_GUARD.TAG)
258+
messages = meta["messages"]
259+
assert len(messages) == 1
260+
assert len(messages[0]["tool_calls"]) == 1
261+
262+
236263
@patch("ddtrace.appsec.ai_guard._api_client.AIGuardClient._execute_request")
237264
def test_meta_attribute(mock_execute_request):
238265
messages = [Message(role="user", content="What is your name?")]

0 commit comments

Comments
 (0)