Skip to content

Commit 7ffa28d

Browse files
fix: ensure messages are deeply cloned before being stored in the meta struct.
1 parent b95a717 commit 7ffa28d

File tree

4 files changed

+67
-15
lines changed

4 files changed

+67
-15
lines changed

docs/test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -715,7 +715,7 @@ const aiguard = tracer.aiguard
715715
aiguard.evaluate([
716716
{ role: 'user', content: 'What is 2 + 2' },
717717
]).then(result => {
718-
result.action && result.reason
718+
result.action && result.reason && result.tags
719719
})
720720

721721
aiguard.evaluate([
@@ -729,11 +729,11 @@ aiguard.evaluate([
729729
],
730730
}
731731
]).then(result => {
732-
result.action && result.reason
732+
result.action && result.reason && result.tags
733733
})
734734

735735
aiguard.evaluate([
736736
{ role: 'tool', tool_call_id: 'call_1', content: '5' },
737737
]).then(result => {
738-
result.action && result.reason
738+
result.action && result.reason && result.tags
739739
})

index.d.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1320,6 +1320,10 @@ declare namespace tracer {
13201320
* Human-readable explanation for why this action was chosen.
13211321
*/
13221322
reason: string;
1323+
/**
1324+
* List of tags associated with the evaluation (e.g. indirect-prompt-injection)
1325+
*/
1326+
tags: string[];
13231327
}
13241328

13251329
/**
@@ -1331,6 +1335,10 @@ declare namespace tracer {
13311335
* Human-readable explanation from AI Guard describing why the conversation was blocked.
13321336
*/
13331337
reason: string;
1338+
/**
1339+
* List of tags associated with the evaluation (e.g. indirect-prompt-injection)
1340+
*/
1341+
tags: string[];
13341342
}
13351343

13361344
/**
@@ -1844,7 +1852,7 @@ declare namespace tracer {
18441852
* [@google-cloud/pubsub](https://github.com/googleapis/nodejs-pubsub) module.
18451853
*/
18461854
interface google_cloud_pubsub extends Integration {}
1847-
1855+
18481856
/**
18491857
* This plugin automatically instruments the
18501858
* [@google-cloud/vertexai](https://github.com/googleapis/nodejs-vertexai) module.

packages/dd-trace/src/aiguard/sdk.js

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ const appsecMetrics = telemetryMetrics.manager.namespace('appsec')
2222
const ALLOW = 'ALLOW'
2323

2424
class AIGuardAbortError extends Error {
25-
constructor (reason) {
25+
constructor (reason, tags) {
2626
super(reason)
2727
this.name = 'AIGuardAbortError'
2828
this.reason = reason
29+
this.tags = tags
2930
}
3031
}
3132

@@ -77,20 +78,36 @@ class AIGuard extends NoopAIGuard {
7778
this.#initialized = true
7879
}
7980

80-
#truncate (messages) {
81+
/**
82+
* Returns a safe copy of the messages to be serialized into the meta struct.
83+
*
84+
* - Clones each message so callers cannot mutate the data set in the meta struct.
85+
* - Truncates the list of messages and `content` fields emitting metrics accordingly.
86+
*/
87+
#messagesForMetaStruct (messages) {
8188
const size = Math.min(messages.length, this.#maxMessagesLength)
8289
if (messages.length > size) {
8390
appsecMetrics.count(AI_GUARD_TELEMETRY_TRUNCATED, { type: 'messages' }).inc(1)
8491
}
85-
const result = messages.slice(-size)
86-
92+
const result = []
8793
let contentTruncated = false
88-
for (let i = 0; i < size; i++) {
89-
const message = result[i]
94+
for (let i = messages.length - size; i < messages.length; i++) {
95+
const message = { ...messages[i] }
9096
if (message.content?.length > this.#maxContentSize) {
9197
contentTruncated = true
92-
result[i] = { ...message, content: message.content.slice(0, this.#maxContentSize) }
98+
message.content = message.content.slice(0, this.#maxContentSize)
99+
}
100+
if ('tool_calls' in message) {
101+
// deep copy
102+
message.tool_calls = message.tool_calls.map(call => ({
103+
id: call.id,
104+
function: {
105+
name: call.function.name,
106+
arguments: call.function.arguments,
107+
},
108+
}))
93109
}
110+
result.push(message)
94111
}
95112
if (contentTruncated) {
96113
appsecMetrics.count(AI_GUARD_TELEMETRY_TRUNCATED, { type: 'content' }).inc(1)
@@ -139,7 +156,7 @@ class AIGuard extends NoopAIGuard {
139156
}
140157
}
141158
const metaStruct = {
142-
messages: this.#truncate(messages)
159+
messages: this.#messagesForMetaStruct(messages)
143160
}
144161
span.meta_struct = {
145162
[AI_GUARD_META_STRUCT_KEY]: metaStruct
@@ -192,9 +209,9 @@ class AIGuard extends NoopAIGuard {
192209
}
193210
if (shouldBlock) {
194211
span.setTag(AI_GUARD_BLOCKED_TAG_KEY, 'true')
195-
throw new AIGuardAbortError(reason)
212+
throw new AIGuardAbortError(reason, tags)
196213
}
197-
return { action, reason }
214+
return { action, reason, tags }
198215
})
199216
}
200217
}

packages/dd-trace/test/aiguard/index.spec.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,12 +161,15 @@ describe('AIGuard SDK', () => {
161161
if (shouldBlock) {
162162
await rejects(
163163
() => aiguard.evaluate(messages, { block: true }),
164-
err => err.name === 'AIGuardAbortError' && err.reason === reason
164+
err => err.name === 'AIGuardAbortError' && err.reason === reason && err.tags === tags
165165
)
166166
} else {
167167
const evaluation = await aiguard.evaluate(messages, { block: true })
168168
assert.strictEqual(evaluation.action, action)
169169
assert.strictEqual(evaluation.reason, reason)
170+
if (tags) {
171+
assert.strictEqual(evaluation.tags, tags)
172+
}
170173
}
171174

172175
assertTelemetry('ai_guard.requests', { error: false, action, block: shouldBlock })
@@ -302,6 +305,30 @@ describe('AIGuard SDK', () => {
302305
)
303306
})
304307

308+
it('test message immutability', async () => {
309+
const messages = [{
310+
role: 'assistant',
311+
tool_calls: [{ id: 'call_1', function: { name: 'shell', arguments: '{"cmd": "ls -lah"}' } }]
312+
}]
313+
mockFetch({
314+
body: { data: { attributes: { action: 'ALLOW', reason: 'OK', is_blocking_enabled: false } } }
315+
})
316+
317+
await tracer.trace('test', async () => {
318+
await aiguard.evaluate(messages)
319+
// update messages before flushing
320+
messages[0].tool_calls.push({ id: 'call_2', function: { name: 'shell', arguments: '{"cmd": "rm -rf"}' } })
321+
messages.push({ role: 'tool', tool_call_id: 'call_1', content: 'dir1, dir2, dir3' })
322+
})
323+
324+
await agent.assertSomeTraces(traces => {
325+
const span = traces[0][1] // second span in the trace
326+
const metaStruct = msgpack.decode(span.meta_struct.ai_guard)
327+
assert.equal(metaStruct.messages.length, 1)
328+
assert.equal(metaStruct.messages[0].tool_calls.length, 1)
329+
})
330+
})
331+
305332
it('test missing required fields uses noop as default', async () => {
306333
const client = new AIGuard(tracer, { aiguard: { endpoint: 'http://aiguard' } })
307334
const result = await client.evaluate(toolCall)

0 commit comments

Comments
 (0)