@@ -154,10 +154,13 @@ func (e *LibraryGuardrailEngine) structuredIfFileMode() *models.StructuredGuardr
154154}
155155
156156// CheckInbound validates an inbound (user) message via InputGate.
157- func (e * LibraryGuardrailEngine ) CheckInbound (ctx context.Context , msg * a2a.Message ) error {
157+ // The returned PolicyResult carries the engine's Allow/Deny/Modify
158+ // decision (see #209). On Modify, msg.Parts is mutated in place so
159+ // downstream reads see the redacted text.
160+ func (e * LibraryGuardrailEngine ) CheckInbound (ctx context.Context , msg * a2a.Message ) (coreruntime.PolicyResult , error ) {
158161 text := coreruntime .ExtractText (msg )
159162 if text == "" {
160- return nil
163+ return coreruntime . Allow (), nil
161164 }
162165
163166 // Span lifecycle (issue #161): open before the library call so the
@@ -186,8 +189,7 @@ func (e *LibraryGuardrailEngine) CheckInbound(ctx context.Context, msg *a2a.Mess
186189 })
187190 if err != nil {
188191 e .logger .Warn ("guardrail input gate error" , map [string ]any {"error" : err .Error ()})
189- result = nil
190- return nil
192+ return coreruntime .Allow (), nil
191193 }
192194
193195 switch result .Decision {
@@ -202,47 +204,66 @@ func (e *LibraryGuardrailEngine) CheckInbound(ctx context.Context, msg *a2a.Mess
202204 e .emitGuardrailEvent (ctx , "" , result .MaskedContent , guardrailResultMasked , result )
203205 evidenceContent = result .MaskedContent
204206 decisionString = guardrailResultMasked
207+ return coreruntime .Modify (result .MaskedContent , violationSummary (result )), nil
205208 }
206209 case guardrails .DecisionBlock :
207210 desc := violationSummary (result )
208211 if e .enforce {
209212 e .emitGuardrailEvent (ctx , "" , text , guardrailResultBlocked , result )
210213 evidenceContent = text
211214 decisionString = guardrailResultBlocked
212- return fmt .Errorf ("input blocked: %s" , desc )
215+ return coreruntime . Deny ( desc ), fmt .Errorf ("input blocked: %s" , desc )
213216 }
214217 e .logger .Warn ("guardrail input violation (warn mode)" , map [string ]any {"detail" : desc })
215218 e .emitGuardrailEvent (ctx , "" , text , guardrailResultWarned , result )
216219 evidenceContent = text
217220 decisionString = guardrailResultWarned
218221 }
219- return nil
222+ return coreruntime . Allow (), nil
220223}
221224
222225// CheckOutbound validates an outbound (agent) message via OutputGate.
223226// Masked content is applied in-place; blocked content returns an error
224227// only in enforce mode. One guardrail.output span per text part — the
225228// trace tree mirrors the part-level iteration.
226- func (e * LibraryGuardrailEngine ) CheckOutbound (ctx context.Context , msg * a2a.Message ) error {
229+ //
230+ // The aggregated PolicyResult follows the MOST-RESTRICTIVE part's
231+ // outcome per the PolicyDecision ordering (Allow < Modify < StepUp <
232+ // Defer < Deny). The strict `>` comparison keeps the FIRST part at
233+ // each severity level — subsequent equal-severity parts do not
234+ // overwrite Modified. In practice callers today only inspect the
235+ // mutated msg.Parts (each part carries its own redaction) — the
236+ // aggregate.Modified string is scaffolding for future R4b/R4c
237+ // callers that need a single-string projection.
238+ func (e * LibraryGuardrailEngine ) CheckOutbound (ctx context.Context , msg * a2a.Message ) (coreruntime.PolicyResult , error ) {
239+ aggregate := coreruntime .Allow ()
227240 for i , p := range msg .Parts {
228241 if p .Kind != a2a .PartKindText || p .Text == "" {
229242 continue
230243 }
231244
232245 original := p .Text
233- blockErr := e .checkOneOutboundPart (ctx , msg , i , original )
246+ partResult , blockErr := e .checkOneOutboundPart (ctx , msg , i , original )
234247 if blockErr != nil {
235- return blockErr
248+ return partResult , blockErr
249+ }
250+ // Escalate aggregate when this part is more restrictive. Uses
251+ // the PolicyDecision ordinal-as-severity contract established
252+ // in forge-core/runtime/guardrails.go: constants are ordered
253+ // Allow < Modify < StepUp < Defer < Deny, so a numeric > is
254+ // safe here even when StepUp/Defer flow through in future.
255+ if partResult .Decision > aggregate .Decision {
256+ aggregate = partResult
236257 }
237258 }
238- return nil
259+ return aggregate , nil
239260}
240261
241262// checkOneOutboundPart runs OutputGate over a single text part. Split
242263// from CheckOutbound so the per-part span has a clean lifetime (open
243264// at function entry, deferred close at exit) without juggling state
244265// across iterations.
245- func (e * LibraryGuardrailEngine ) checkOneOutboundPart (ctx context.Context , msg * a2a.Message , i int , original string ) error {
266+ func (e * LibraryGuardrailEngine ) checkOneOutboundPart (ctx context.Context , msg * a2a.Message , i int , original string ) (coreruntime. PolicyResult , error ) {
246267 ctx , span := startGuardrailSpan (ctx , "output" , "" )
247268 var (
248269 evidenceContent string
@@ -263,8 +284,7 @@ func (e *LibraryGuardrailEngine) checkOneOutboundPart(ctx context.Context, msg *
263284 })
264285 if err != nil {
265286 e .logger .Warn ("guardrail output gate error" , map [string ]any {"error" : err .Error ()})
266- result = nil
267- return nil
287+ return coreruntime .Allow (), nil
268288 }
269289
270290 switch result .Decision {
@@ -275,21 +295,22 @@ func (e *LibraryGuardrailEngine) checkOneOutboundPart(ctx context.Context, msg *
275295 e .emitGuardrailEvent (ctx , "" , result .MaskedContent , guardrailResultMasked , result )
276296 evidenceContent = result .MaskedContent
277297 decisionString = guardrailResultMasked
298+ return coreruntime .Modify (result .MaskedContent , violationSummary (result )), nil
278299 }
279300 case guardrails .DecisionBlock :
280301 desc := violationSummary (result )
281302 if e .enforce {
282303 e .emitGuardrailEvent (ctx , "" , original , guardrailResultBlocked , result )
283304 evidenceContent = original
284305 decisionString = guardrailResultBlocked
285- return fmt .Errorf ("output blocked: %s" , desc )
306+ return coreruntime . Deny ( desc ), fmt .Errorf ("output blocked: %s" , desc )
286307 }
287308 e .logger .Warn ("guardrail output violation (warn mode)" , map [string ]any {"detail" : desc })
288309 e .emitGuardrailEvent (ctx , "" , original , guardrailResultWarned , result )
289310 evidenceContent = original
290311 decisionString = guardrailResultWarned
291312 }
292- return nil
313+ return coreruntime . Allow (), nil
293314}
294315
295316// CheckToolCall validates the arguments the agent is about to pass to
@@ -324,7 +345,6 @@ func (e *LibraryGuardrailEngine) CheckToolCall(ctx context.Context, toolName, ar
324345 "tool" : toolName ,
325346 "error" : err .Error (),
326347 })
327- result = nil
328348 return args , nil
329349 }
330350
@@ -391,7 +411,6 @@ func (e *LibraryGuardrailEngine) CheckToolOutput(ctx context.Context, toolName,
391411 "tool" : toolName ,
392412 "error" : err .Error (),
393413 })
394- result = nil
395414 return text , nil
396415 }
397416
@@ -453,7 +472,6 @@ func (e *LibraryGuardrailEngine) CheckContext(ctx context.Context, content strin
453472 })
454473 if err != nil {
455474 e .logger .Warn ("guardrail context gate error" , map [string ]any {"error" : err .Error ()})
456- result = nil
457475 return content , nil
458476 }
459477
@@ -514,7 +532,6 @@ func (e *LibraryGuardrailEngine) CheckStream(ctx context.Context, chunk string)
514532 })
515533 if err != nil {
516534 e .logger .Warn ("guardrail stream gate error" , map [string ]any {"error" : err .Error ()})
517- result = nil
518535 return chunk , nil
519536 }
520537
0 commit comments