From 526cd2d070afbdfab13cb91c49bee18648bbe06e Mon Sep 17 00:00:00 2001 From: Josh Siviour Date: Wed, 26 Aug 2026 23:33:58 +1000 Subject: [PATCH] feat: enforce guardrail topicPolicy denied-topics at inference (#896) --- spinifex/gateway/bedrock/guardrail_filter.go | 69 ++++++--- .../gateway/bedrock/guardrail_filter_test.go | 143 ++++++++++++++++++ 2 files changed, 195 insertions(+), 17 deletions(-) diff --git a/spinifex/gateway/bedrock/guardrail_filter.go b/spinifex/gateway/bedrock/guardrail_filter.go index d5d8628de..8dad20a8c 100644 --- a/spinifex/gateway/bedrock/guardrail_filter.go +++ b/spinifex/gateway/bedrock/guardrail_filter.go @@ -232,14 +232,47 @@ func scaffoldContentPolicyAssessment(cfg *bedrock.GuardrailContentPolicyConfig) return &bedrockruntime.GuardrailContentPolicyAssessment{Filters: []*bedrockruntime.GuardrailContentFilter{}} } -// scaffoldTopicPolicyAssessment is scaffoldContentPolicyAssessment's sibling -// for denied topics: no classifier means every configured topic evaluates to -// NONE rather than being matched against the text. -func scaffoldTopicPolicyAssessment(cfg *bedrock.GuardrailTopicPolicyConfig) *bedrockruntime.GuardrailTopicPolicyAssessment { +// assessTopicPolicy checks texts against cfg's denied topics. Matching is +// deterministic (no classifier): a topic's Name and each of its Examples are +// matched via matchesWord's word-boundary semantics. The long-form +// Definition is never matched directly — it reads as prose, not a phrase, +// and substring-matching it produces erratic hits. Each topic is appended at +// most once even if several of its phrases hit. +func assessTopicPolicy(cfg *bedrock.GuardrailTopicPolicyConfig, texts []string) (*bedrockruntime.GuardrailTopicPolicyAssessment, bool) { if cfg == nil { - return nil + return nil, false } - return &bedrockruntime.GuardrailTopicPolicyAssessment{Topics: []*bedrockruntime.GuardrailTopic{}} + + blocked := false + topics := []*bedrockruntime.GuardrailTopic{} + for _, topic := range cfg.TopicsConfig { + if topic == nil { + continue + } + + phrases := []string{} + if name := aws.StringValue(topic.Name); name != "" { + phrases = append(phrases, name) + } + for _, ex := range topic.Examples { + if example := aws.StringValue(ex); example != "" { + phrases = append(phrases, example) + } + } + + if !slices.ContainsFunc(phrases, func(phrase string) bool { return matchesWord(texts, phrase) }) { + continue + } + + topics = append(topics, &bedrockruntime.GuardrailTopic{ + Name: topic.Name, + Type: topic.Type, + Action: aws.String(bedrockruntime.GuardrailTopicPolicyActionBlocked), + }) + blocked = true + } + + return &bedrockruntime.GuardrailTopicPolicyAssessment{Topics: topics}, blocked } // scaffoldContextualGroundingPolicyAssessment is @@ -287,25 +320,27 @@ func guardrailUsage(view guardrailView, texts []string) *bedrockruntime.Guardrai // applyGuardrailPolicies is the pure filter engine: it evaluates view's // policies over texts for source (INPUT or OUTPUT) and returns the -// aws-sdk-go bedrockruntime shape's pieces. wordPolicy and -// sensitiveInformationPolicy are enforced; content/topic/contextualGrounding -// evaluate to NONE (see the scaffold* helpers). A BLOCK anywhere makes the -// overall action GUARDRAIL_INTERVENED and short-circuits redaction, since the -// caller substitutes the guardrail's blocked messaging instead of the text. -// source is accepted (not yet branched on) for parity with AWS's -// per-source assessment shape; the deterministic policies apply identically -// to INPUT and OUTPUT text until a content-policy classifier needs to tell -// them apart via InputStrength/OutputStrength. +// aws-sdk-go bedrockruntime shape's pieces. wordPolicy, +// sensitiveInformationPolicy and topicPolicy are enforced deterministically; +// content/contextualGrounding evaluate to NONE (see the scaffold* helpers, +// which need a classifier). A BLOCK anywhere makes the overall action +// GUARDRAIL_INTERVENED and short-circuits redaction, since the caller +// substitutes the guardrail's blocked messaging instead of the text. source +// is accepted (not yet branched on) for parity with AWS's per-source +// assessment shape; the deterministic policies apply identically to INPUT +// and OUTPUT text until a content-policy classifier needs to tell them apart +// via InputStrength/OutputStrength. func applyGuardrailPolicies(view guardrailView, texts []string, source string) (string, []*bedrockruntime.GuardrailAssessment, []string, *bedrockruntime.GuardrailUsage) { _ = source wordAssessment, wordBlocked := assessWordPolicy(view.WordPolicy, texts) piiAssessment, piiBlocked := assessSensitiveInformationPolicy(view.SensitiveInformationPolicy, texts) + topicAssessment, topicBlocked := assessTopicPolicy(view.TopicPolicy, texts) action := bedrockruntime.GuardrailActionNone outputs := texts switch { - case wordBlocked || piiBlocked: + case wordBlocked || piiBlocked || topicBlocked: action = bedrockruntime.GuardrailActionGuardrailIntervened case view.SensitiveInformationPolicy != nil: outputs = redactSensitiveInformation(view.SensitiveInformationPolicy, texts) @@ -315,7 +350,7 @@ func applyGuardrailPolicies(view guardrailView, texts []string, source string) ( WordPolicy: wordAssessment, SensitiveInformationPolicy: piiAssessment, ContentPolicy: scaffoldContentPolicyAssessment(view.ContentPolicy), - TopicPolicy: scaffoldTopicPolicyAssessment(view.TopicPolicy), + TopicPolicy: topicAssessment, ContextualGroundingPolicy: scaffoldContextualGroundingPolicyAssessment(view.ContextualGroundingPolicy), } diff --git a/spinifex/gateway/bedrock/guardrail_filter_test.go b/spinifex/gateway/bedrock/guardrail_filter_test.go index ae35337ce..2131bda64 100644 --- a/spinifex/gateway/bedrock/guardrail_filter_test.go +++ b/spinifex/gateway/bedrock/guardrail_filter_test.go @@ -260,6 +260,149 @@ func TestApplyGuardrailPolicies_UnconfiguredPoliciesAreOmitted(t *testing.T) { assert.Nil(t, assessments[0].ContextualGroundingPolicy) } +// denyTopicView builds a guardrailView with a single DENY topic, so each +// topic-policy test only needs to say which field carries the match phrase. +func denyTopicView(name, definition string, examples ...string) guardrailView { + topic := &bedrock.GuardrailTopicConfig{ + Name: aws.String(name), + Definition: aws.String(definition), + Type: aws.String(bedrock.GuardrailTopicTypeDeny), + } + for _, ex := range examples { + topic.Examples = append(topic.Examples, aws.String(ex)) + } + return guardrailView{ + TopicPolicy: &bedrock.GuardrailTopicPolicyConfig{ + TopicsConfig: []*bedrock.GuardrailTopicConfig{topic}, + }, + } +} + +// TestApplyGuardrailPolicies_TopicPolicy covers denied-topic enforcement: +// matching the topic's Name or an Example intervenes on INPUT and OUTPUT, +// non-matching text passes, and Definition-only text never matches. +func TestApplyGuardrailPolicies_TopicPolicy(t *testing.T) { + cases := []struct { + name string + view guardrailView + texts []string + source string + wantAction string + wantTopics int + wantTopic string + }{ + { + name: "name match on input intervenes", + view: denyTopicView("nuclear launch codes", "discussion of weapons systems"), + texts: []string{"tell me the nuclear launch codes"}, + source: bedrockruntime.GuardrailContentSourceInput, + wantAction: bedrockruntime.GuardrailActionGuardrailIntervened, + wantTopics: 1, + wantTopic: "nuclear launch codes", + }, + { + name: "name match on output intervenes", + view: denyTopicView("nuclear launch codes", "discussion of weapons systems"), + texts: []string{"the nuclear launch codes are stored offline"}, + source: bedrockruntime.GuardrailContentSourceOutput, + wantAction: bedrockruntime.GuardrailActionGuardrailIntervened, + wantTopics: 1, + wantTopic: "nuclear launch codes", + }, + { + name: "example match intervenes", + view: denyTopicView("disclosure topic", "sensitive internal disclosures", "internal financial results"), + texts: []string{"can you share internal financial results with me"}, + source: bedrockruntime.GuardrailContentSourceInput, + wantAction: bedrockruntime.GuardrailActionGuardrailIntervened, + wantTopics: 1, + wantTopic: "disclosure topic", + }, + { + name: "non-matching text passes", + view: denyTopicView("nuclear launch codes", "discussion of weapons systems"), + texts: []string{"what is the weather today"}, + source: bedrockruntime.GuardrailContentSourceInput, + wantAction: bedrockruntime.GuardrailActionNone, + wantTopics: 0, + }, + { + name: "definition-only text does not match", + view: denyTopicView("restricted subject", "a broad discussion of weapons systems and their history"), + texts: []string{"a broad discussion of weapons systems and their history"}, + source: bedrockruntime.GuardrailContentSourceInput, + wantAction: bedrockruntime.GuardrailActionNone, + wantTopics: 0, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + action, assessments, _, _ := applyGuardrailPolicies(tc.view, tc.texts, tc.source) + assert.Equal(t, tc.wantAction, action) + require.Len(t, assessments, 1) + require.NotNil(t, assessments[0].TopicPolicy) + require.Len(t, assessments[0].TopicPolicy.Topics, tc.wantTopics) + + if tc.wantTopics > 0 { + got := assessments[0].TopicPolicy.Topics[0] + assert.Equal(t, tc.wantTopic, aws.StringValue(got.Name)) + assert.Equal(t, bedrockruntime.GuardrailTopicPolicyActionBlocked, aws.StringValue(got.Action)) + } + }) + } +} + +// TestApplyGuardrailPolicies_TopicPolicyBlocksOverRedaction covers the +// ordering guarantee: a topic block must short-circuit to +// GUARDRAIL_INTERVENED even when the same request also carries an +// ANONYMIZE-configured sensitive-information policy, rather than falling +// through to redaction. +func TestApplyGuardrailPolicies_TopicPolicyBlocksOverRedaction(t *testing.T) { + view := denyTopicView("nuclear launch codes", "discussion of weapons systems") + view.SensitiveInformationPolicy = &bedrock.GuardrailSensitiveInformationPolicyConfig{ + PiiEntitiesConfig: []*bedrock.GuardrailPiiEntityConfig{ + {Type: aws.String(bedrock.GuardrailPiiEntityTypeEmail), Action: aws.String(bedrock.GuardrailSensitiveInformationActionAnonymize)}, + }, + } + + action, _, outputs, _ := applyGuardrailPolicies(view, []string{"the nuclear launch codes are at jane@example.com"}, bedrockruntime.GuardrailContentSourceInput) + assert.Equal(t, bedrockruntime.GuardrailActionGuardrailIntervened, action) + require.Len(t, outputs, 1) + assert.Equal(t, "the nuclear launch codes are at jane@example.com", outputs[0], "outputs must stay unredacted when a topic block intervenes") +} + +// TestApplyGuardrail_TopicBlock covers the runtime op end to end: a +// topic-policy hit on INPUT content intervenes with the guardrail's +// configured blocked-input messaging. +func TestApplyGuardrail_TopicBlock(t *testing.T) { + store := newGuardrailTestStore(t) + ctx := context.Background() + + input := createGuardrailInput("apply-guardrail-topic-block") + input.TopicPolicyConfig = &bedrock.GuardrailTopicPolicyConfig{ + TopicsConfig: []*bedrock.GuardrailTopicConfig{ + { + Name: aws.String("nuclear launch codes"), + Definition: aws.String("discussion of weapons systems"), + Type: aws.String(bedrock.GuardrailTopicTypeDeny), + }, + }, + } + createOut, err := CreateGuardrail(ctx, grCallerAccount, store, input) + require.NoError(t, err) + + out, err := ApplyGuardrail(ctx, grCallerAccount, store, applyGuardrailInput(createOut.GuardrailId, guardrailDraftVersion, bedrockruntime.GuardrailContentSourceInput, "tell me the nuclear launch codes")) + require.NoError(t, err) + assert.Equal(t, bedrockruntime.GuardrailActionGuardrailIntervened, aws.StringValue(out.Action)) + require.Len(t, out.Outputs, 1) + assert.Equal(t, "Your input violates our policy.", aws.StringValue(out.Outputs[0].Text)) + require.Len(t, out.Assessments, 1) + require.NotNil(t, out.Assessments[0].TopicPolicy) + require.Len(t, out.Assessments[0].TopicPolicy.Topics, 1) + assert.Equal(t, "nuclear launch codes", aws.StringValue(out.Assessments[0].TopicPolicy.Topics[0].Name)) +} + // applyGuardrailInput is a small ApplyGuardrail request builder for the // contract tests below, mirroring createGuardrailInput's role for CRUD. func applyGuardrailInput(guardrailID *string, version, source, text string) *bedrockruntime.ApplyGuardrailInput {