Skip to content

Commit 4301536

Browse files
committed
Fix sanitizer fail-closed gap and non-PII audit misclassification
- Fail closed when the guardrail intervenes but the masked output is whitespace-only (IsNullOrWhiteSpace, not IsNullOrEmpty); whitespace carries no sanitized content and must not be returned as-is. - Classify the redaction audit event by whether PII entities were actually reported: a non-PII intervention (content/topic/word filter) is now recorded as a generic guardrail_intervention under the bedrock-guardrails policy rather than always claiming pii_redaction.
1 parent f66a30b commit 4301536

4 files changed

Lines changed: 66 additions & 7 deletions

File tree

src/AWS.Bedrock.MAG/Mcp/BedrockGuardrailsSanitizer.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,10 @@ public async Task<SanitizationResult> SanitizeAsync(string text, CancellationTok
6262
var detected = GuardrailResponseMapper.GetDetectedPiiTypes(response);
6363
var masked = response.Outputs?.FirstOrDefault()?.Text;
6464

65-
// Block explicitly, or fail closed when the guardrail intervened but returned no masked text.
66-
// Never fall back to the original content: intervention means it was not safe to return as-is.
67-
if (_options.BlockOnMatch || string.IsNullOrEmpty(masked))
65+
// Block explicitly, or fail closed when the guardrail intervened but returned no usable masked
66+
// text (null, empty, or whitespace-only). Never fall back to the original content: intervention
67+
// means it was not safe to return as-is, and whitespace-only output carries no sanitized content.
68+
if (_options.BlockOnMatch || string.IsNullOrWhiteSpace(masked))
6869
{
6970
return new SanitizationResult { Text = BlockedPlaceholder, RedactedTypes = detected, Blocked = true, Intervened = true };
7071
}

src/AWS.Bedrock.MAG/Mcp/GovernedBedrockMcpServerTool.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ void Track(SanitizationResult sanitized)
111111
return result;
112112
}
113113

114-
EmitRedaction(redactedTypes, blocked);
114+
EmitInterventionEvent(redactedTypes, blocked);
115115

116116
// Scrubbing covers text blocks and embedded text resources. Not scrubbed (documented v1 limits):
117117
// StructuredContent (sanitizing arbitrary structured JSON is a post-v1 follow-up; callers with PII
@@ -126,23 +126,27 @@ void Track(SanitizationResult sanitized)
126126
};
127127
}
128128

129-
private void EmitRedaction(IReadOnlyCollection<string> redactedTypes, bool blocked)
129+
private void EmitInterventionEvent(IReadOnlyCollection<string> redactedTypes, bool blocked)
130130
{
131131
if (_audit is null)
132132
{
133133
return;
134134
}
135135

136+
// An intervention is either a PII redaction (entities enumerated) or a non-PII policy such as a
137+
// content/topic/word filter (no entities). Classify the audit event by what actually fired instead
138+
// of always labeling it a PII redaction, which the sanitizer explicitly supports for non-PII cases.
139+
var isPii = redactedTypes.Count > 0;
136140
var toolName = _inner.ProtocolTool.Name;
137141
_audit.Emit(new GovernanceEvent
138142
{
139143
Type = GovernanceEventType.PolicyViolation,
140144
AgentId = $"mcp-tool:{toolName}",
141145
SessionId = "mcp-response-sanitization",
142-
PolicyName = "bedrock-guardrails-pii",
146+
PolicyName = isPii ? "bedrock-guardrails-pii" : "bedrock-guardrails",
143147
Data =
144148
{
145-
["kind"] = "pii_redaction",
149+
["kind"] = isPii ? "pii_redaction" : "guardrail_intervention",
146150
["tool"] = toolName,
147151
["entities"] = string.Join(",", redactedTypes),
148152
["blocked"] = blocked

test/AWS.Bedrock.MAG.UnitTests/Mcp/BedrockGuardrailsSanitizerTests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,5 +159,33 @@ public async Task Fails_closed_when_intervened_but_no_masked_output()
159159
Assert.True(result.Blocked);
160160
Assert.DoesNotContain("123-45-6789", result.Text);
161161
}
162+
163+
[Fact]
164+
public async Task Fails_closed_when_intervened_but_masked_output_is_whitespace_only()
165+
{
166+
// Whitespace-only masked output carries no sanitized content; treating it as valid would return an
167+
// effectively empty result for content the guardrail flagged as unsafe. Must fail closed.
168+
var response = new ApplyGuardrailResponse
169+
{
170+
Action = GuardrailAction.GUARDRAIL_INTERVENED,
171+
Outputs = new List<GuardrailOutputContent> { new() { Text = " \t\n" } },
172+
Assessments = new List<GuardrailAssessment>
173+
{
174+
new GuardrailAssessment
175+
{
176+
SensitiveInformationPolicy = new GuardrailSensitiveInformationPolicyAssessment
177+
{
178+
PiiEntities = new List<GuardrailPiiEntityFilter> { new() { Type = new GuardrailPiiEntityType("US_SSN") } }
179+
}
180+
}
181+
}
182+
};
183+
var sanitizer = Sanitizer(Mock(response).Object);
184+
185+
var result = await sanitizer.SanitizeAsync("SSN 123-45-6789");
186+
187+
Assert.True(result.Blocked);
188+
Assert.DoesNotContain("123-45-6789", result.Text);
189+
}
162190
}
163191
}

test/AWS.Bedrock.MAG.UnitTests/Mcp/GovernedBedrockMcpServerToolTests.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,35 @@ public async Task Emits_governance_event_on_redaction()
9191
var evt = Assert.Single(captured);
9292
Assert.Equal(GovernanceEventType.PolicyViolation, evt.Type);
9393
Assert.Equal("pii_redaction", evt.Data["kind"]);
94+
Assert.Equal("bedrock-guardrails-pii", evt.PolicyName);
9495
Assert.Contains("US_SSN", evt.Data["entities"]!.ToString());
9596
}
9697

98+
[Fact]
99+
public async Task Emits_generic_intervention_event_when_no_pii_entities()
100+
{
101+
// A non-PII intervention (content/topic/word filter) masks text but enumerates no PII entities.
102+
// The audit event must be classified as a generic guardrail intervention, not a PII redaction.
103+
var response = new ApplyGuardrailResponse
104+
{
105+
Action = GuardrailAction.GUARDRAIL_INTERVENED,
106+
Outputs = new List<GuardrailOutputContent> { new() { Text = "[filtered]" } }
107+
};
108+
var inner = new StubTool("lookup", TextResult("some banned content"));
109+
var sanitizer = Sanitizer(BedrockReturning(response));
110+
var emitter = new AuditEmitter();
111+
var captured = new List<GovernanceEvent>();
112+
emitter.OnAll(captured.Add);
113+
114+
var tool = new GovernedBedrockMcpServerTool(inner, sanitizer, emitter);
115+
await tool.InvokeAsync(McpTest.Request());
116+
117+
var evt = Assert.Single(captured);
118+
Assert.Equal("guardrail_intervention", evt.Data["kind"]);
119+
Assert.Equal("bedrock-guardrails", evt.PolicyName);
120+
Assert.Equal(string.Empty, evt.Data["entities"]!.ToString());
121+
}
122+
97123
[Fact]
98124
public async Task Sanitizes_multiple_text_blocks_and_preserves_non_text_blocks()
99125
{

0 commit comments

Comments
 (0)