Split 6 oversized files (719–882 LOC each) into focused files under 500 LOC: - policy_engine.go → types, loader, eval, gen (4 files) - legal_rag.go → types, client, http, context, scroll (5 files) - ai_act_module.go → module, yaml, obligations (3 files) - nis2_module.go → module, yaml, obligations + shared obligation_yaml_types.go (3+1 files) - financial_policy.go → types, engine (2 files) - dsgvo_module.go → module, yaml, obligations (3 files) All in package ucca, zero exported symbol renames, go test ./internal/ucca/... passes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
477 lines
12 KiB
Go
477 lines
12 KiB
Go
package ucca
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Evaluate runs all YAML rules against the intake
|
|
func (e *PolicyEngine) Evaluate(intake *UseCaseIntake) *AssessmentResult {
|
|
result := &AssessmentResult{
|
|
Feasibility: FeasibilityYES,
|
|
RiskLevel: RiskLevelMINIMAL,
|
|
Complexity: ComplexityLOW,
|
|
RiskScore: 0,
|
|
TriggeredRules: []TriggeredRule{},
|
|
RequiredControls: []RequiredControl{},
|
|
RecommendedArchitecture: []PatternRecommendation{},
|
|
ForbiddenPatterns: []ForbiddenPattern{},
|
|
ExampleMatches: []ExampleMatch{},
|
|
DSFARecommended: false,
|
|
Art22Risk: false,
|
|
TrainingAllowed: TrainingYES,
|
|
}
|
|
|
|
hasBlock := false
|
|
hasWarn := false
|
|
controlSet := make(map[string]bool)
|
|
patternPriority := make(map[string]int)
|
|
triggeredRuleIDs := make(map[string]bool)
|
|
needsEscalation := false
|
|
|
|
priority := 1
|
|
for _, rule := range e.config.Rules {
|
|
if rule.Condition.Aggregate != "" {
|
|
continue
|
|
}
|
|
|
|
if e.evaluateCondition(&rule.Condition, intake) {
|
|
triggeredRuleIDs[rule.ID] = true
|
|
|
|
triggered := TriggeredRule{
|
|
Code: rule.ID,
|
|
Category: rule.Category,
|
|
Title: rule.Title,
|
|
Description: rule.Description,
|
|
Severity: parseSeverity(rule.Severity),
|
|
ScoreDelta: rule.Effect.RiskAdd,
|
|
GDPRRef: rule.GDPRRef,
|
|
Rationale: rule.Rationale,
|
|
}
|
|
result.TriggeredRules = append(result.TriggeredRules, triggered)
|
|
result.RiskScore += rule.Effect.RiskAdd
|
|
|
|
switch parseSeverity(rule.Severity) {
|
|
case SeverityBLOCK:
|
|
hasBlock = true
|
|
case SeverityWARN:
|
|
hasWarn = true
|
|
}
|
|
|
|
if rule.Effect.Feasibility != "" {
|
|
switch rule.Effect.Feasibility {
|
|
case "NO":
|
|
result.Feasibility = FeasibilityNO
|
|
case "CONDITIONAL":
|
|
if result.Feasibility != FeasibilityNO {
|
|
result.Feasibility = FeasibilityCONDITIONAL
|
|
}
|
|
case "YES":
|
|
if result.Feasibility != FeasibilityNO && result.Feasibility != FeasibilityCONDITIONAL {
|
|
result.Feasibility = FeasibilityYES
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, ctrlID := range rule.Effect.ControlsAdd {
|
|
if !controlSet[ctrlID] {
|
|
controlSet[ctrlID] = true
|
|
if ctrl, ok := e.config.Controls[ctrlID]; ok {
|
|
result.RequiredControls = append(result.RequiredControls, RequiredControl{
|
|
ID: ctrl.ID,
|
|
Title: ctrl.Title,
|
|
Description: ctrl.Description,
|
|
Severity: parseSeverity(rule.Severity),
|
|
Category: categorizeControl(ctrl.ID),
|
|
GDPRRef: ctrl.GDPRRef,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, patternID := range rule.Effect.SuggestedPatterns {
|
|
if _, exists := patternPriority[patternID]; !exists {
|
|
patternPriority[patternID] = priority
|
|
priority++
|
|
}
|
|
}
|
|
|
|
if rule.Effect.Escalation {
|
|
needsEscalation = true
|
|
}
|
|
if rule.Effect.Art22Risk {
|
|
result.Art22Risk = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if hasBlock {
|
|
result.Feasibility = FeasibilityNO
|
|
} else if hasWarn && result.Feasibility != FeasibilityNO {
|
|
result.Feasibility = FeasibilityCONDITIONAL
|
|
}
|
|
|
|
result.RiskLevel = e.calculateRiskLevel(result.RiskScore)
|
|
result.Complexity = e.calculateComplexity(result)
|
|
result.DSFARecommended = e.shouldRecommendDSFA(intake, result)
|
|
result.TrainingAllowed = e.determineTrainingAllowed(intake)
|
|
result.RecommendedArchitecture = e.buildPatternRecommendations(patternPriority)
|
|
result.ExampleMatches = MatchExamples(intake)
|
|
result.Summary = e.generateSummary(result, intake)
|
|
result.Recommendation = e.generateRecommendation(result, intake)
|
|
if result.Feasibility == FeasibilityNO {
|
|
result.AlternativeApproach = e.generateAlternative(result, intake, triggeredRuleIDs)
|
|
}
|
|
|
|
_ = needsEscalation
|
|
return result
|
|
}
|
|
|
|
// evaluateCondition recursively evaluates a condition against the intake
|
|
func (e *PolicyEngine) evaluateCondition(cond *ConditionDef, intake *UseCaseIntake) bool {
|
|
if len(cond.AllOf) > 0 {
|
|
for _, subCond := range cond.AllOf {
|
|
if !e.evaluateCondition(&subCond, intake) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
if len(cond.AnyOf) > 0 {
|
|
for _, subCond := range cond.AnyOf {
|
|
if e.evaluateCondition(&subCond, intake) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
if cond.Field != "" {
|
|
return e.evaluateFieldCondition(cond.Field, cond.Operator, cond.Value, intake)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// evaluateFieldCondition evaluates a single field comparison
|
|
func (e *PolicyEngine) evaluateFieldCondition(field, operator string, value interface{}, intake *UseCaseIntake) bool {
|
|
fieldValue := e.getFieldValue(field, intake)
|
|
if fieldValue == nil {
|
|
return false
|
|
}
|
|
|
|
switch operator {
|
|
case "equals":
|
|
return e.compareEquals(fieldValue, value)
|
|
case "not_equals":
|
|
return !e.compareEquals(fieldValue, value)
|
|
case "in":
|
|
return e.compareIn(fieldValue, value)
|
|
case "contains":
|
|
return e.compareContains(fieldValue, value)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// getFieldValue extracts a field value from the intake using dot notation
|
|
func (e *PolicyEngine) getFieldValue(field string, intake *UseCaseIntake) interface{} {
|
|
parts := strings.Split(field, ".")
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
switch parts[0] {
|
|
case "data_types":
|
|
if len(parts) < 2 {
|
|
return nil
|
|
}
|
|
return e.getDataTypeValue(parts[1], intake)
|
|
case "purpose":
|
|
if len(parts) < 2 {
|
|
return nil
|
|
}
|
|
return e.getPurposeValue(parts[1], intake)
|
|
case "automation":
|
|
return string(intake.Automation)
|
|
case "outputs":
|
|
if len(parts) < 2 {
|
|
return nil
|
|
}
|
|
return e.getOutputsValue(parts[1], intake)
|
|
case "hosting":
|
|
if len(parts) < 2 {
|
|
return nil
|
|
}
|
|
return e.getHostingValue(parts[1], intake)
|
|
case "model_usage":
|
|
if len(parts) < 2 {
|
|
return nil
|
|
}
|
|
return e.getModelUsageValue(parts[1], intake)
|
|
case "domain":
|
|
return string(intake.Domain)
|
|
case "retention":
|
|
if len(parts) < 2 {
|
|
return nil
|
|
}
|
|
return e.getRetentionValue(parts[1], intake)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *PolicyEngine) getDataTypeValue(field string, intake *UseCaseIntake) interface{} {
|
|
switch field {
|
|
case "personal_data":
|
|
return intake.DataTypes.PersonalData
|
|
case "article_9_data":
|
|
return intake.DataTypes.Article9Data
|
|
case "minor_data":
|
|
return intake.DataTypes.MinorData
|
|
case "license_plates":
|
|
return intake.DataTypes.LicensePlates
|
|
case "images":
|
|
return intake.DataTypes.Images
|
|
case "audio":
|
|
return intake.DataTypes.Audio
|
|
case "location_data":
|
|
return intake.DataTypes.LocationData
|
|
case "biometric_data":
|
|
return intake.DataTypes.BiometricData
|
|
case "financial_data":
|
|
return intake.DataTypes.FinancialData
|
|
case "employee_data":
|
|
return intake.DataTypes.EmployeeData
|
|
case "customer_data":
|
|
return intake.DataTypes.CustomerData
|
|
case "public_data":
|
|
return intake.DataTypes.PublicData
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *PolicyEngine) getPurposeValue(field string, intake *UseCaseIntake) interface{} {
|
|
switch field {
|
|
case "customer_support":
|
|
return intake.Purpose.CustomerSupport
|
|
case "marketing":
|
|
return intake.Purpose.Marketing
|
|
case "analytics":
|
|
return intake.Purpose.Analytics
|
|
case "automation":
|
|
return intake.Purpose.Automation
|
|
case "evaluation_scoring":
|
|
return intake.Purpose.EvaluationScoring
|
|
case "decision_making":
|
|
return intake.Purpose.DecisionMaking
|
|
case "profiling":
|
|
return intake.Purpose.Profiling
|
|
case "research":
|
|
return intake.Purpose.Research
|
|
case "internal_tools":
|
|
return intake.Purpose.InternalTools
|
|
case "public_service":
|
|
return intake.Purpose.PublicService
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *PolicyEngine) getOutputsValue(field string, intake *UseCaseIntake) interface{} {
|
|
switch field {
|
|
case "recommendations_to_users":
|
|
return intake.Outputs.RecommendationsToUsers
|
|
case "rankings_or_scores":
|
|
return intake.Outputs.RankingsOrScores
|
|
case "legal_effects":
|
|
return intake.Outputs.LegalEffects
|
|
case "access_decisions":
|
|
return intake.Outputs.AccessDecisions
|
|
case "content_generation":
|
|
return intake.Outputs.ContentGeneration
|
|
case "data_export":
|
|
return intake.Outputs.DataExport
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *PolicyEngine) getHostingValue(field string, intake *UseCaseIntake) interface{} {
|
|
switch field {
|
|
case "provider":
|
|
return intake.Hosting.Provider
|
|
case "region":
|
|
return intake.Hosting.Region
|
|
case "data_residency":
|
|
return intake.Hosting.DataResidency
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *PolicyEngine) getModelUsageValue(field string, intake *UseCaseIntake) interface{} {
|
|
switch field {
|
|
case "rag":
|
|
return intake.ModelUsage.RAG
|
|
case "finetune":
|
|
return intake.ModelUsage.Finetune
|
|
case "training":
|
|
return intake.ModelUsage.Training
|
|
case "inference":
|
|
return intake.ModelUsage.Inference
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *PolicyEngine) getRetentionValue(field string, intake *UseCaseIntake) interface{} {
|
|
switch field {
|
|
case "store_prompts":
|
|
return intake.Retention.StorePrompts
|
|
case "store_responses":
|
|
return intake.Retention.StoreResponses
|
|
case "retention_days":
|
|
return intake.Retention.RetentionDays
|
|
case "anonymize_after_use":
|
|
return intake.Retention.AnonymizeAfterUse
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *PolicyEngine) compareEquals(fieldValue, expected interface{}) bool {
|
|
if bv, ok := fieldValue.(bool); ok {
|
|
if eb, ok := expected.(bool); ok {
|
|
return bv == eb
|
|
}
|
|
}
|
|
if sv, ok := fieldValue.(string); ok {
|
|
if es, ok := expected.(string); ok {
|
|
return sv == es
|
|
}
|
|
}
|
|
if iv, ok := fieldValue.(int); ok {
|
|
switch ev := expected.(type) {
|
|
case int:
|
|
return iv == ev
|
|
case float64:
|
|
return iv == int(ev)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (e *PolicyEngine) compareIn(fieldValue, expected interface{}) bool {
|
|
list, ok := expected.([]interface{})
|
|
if !ok {
|
|
return false
|
|
}
|
|
sv, ok := fieldValue.(string)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, item := range list {
|
|
if is, ok := item.(string); ok && is == sv {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (e *PolicyEngine) compareContains(fieldValue, expected interface{}) bool {
|
|
sv, ok := fieldValue.(string)
|
|
if !ok {
|
|
return false
|
|
}
|
|
es, ok := expected.(string)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return strings.Contains(strings.ToLower(sv), strings.ToLower(es))
|
|
}
|
|
|
|
func (e *PolicyEngine) calculateRiskLevel(score int) RiskLevel {
|
|
t := e.config.Thresholds.Risk
|
|
if score >= t.Unacceptable {
|
|
return RiskLevelUNACCEPTABLE
|
|
}
|
|
if score >= t.High {
|
|
return RiskLevelHIGH
|
|
}
|
|
if score >= t.Medium {
|
|
return RiskLevelMEDIUM
|
|
}
|
|
if score >= t.Low {
|
|
return RiskLevelLOW
|
|
}
|
|
return RiskLevelMINIMAL
|
|
}
|
|
|
|
func (e *PolicyEngine) calculateComplexity(result *AssessmentResult) Complexity {
|
|
controlCount := len(result.RequiredControls)
|
|
if controlCount >= 5 || result.RiskScore >= 50 {
|
|
return ComplexityHIGH
|
|
}
|
|
if controlCount >= 3 || result.RiskScore >= 25 {
|
|
return ComplexityMEDIUM
|
|
}
|
|
return ComplexityLOW
|
|
}
|
|
|
|
func (e *PolicyEngine) shouldRecommendDSFA(intake *UseCaseIntake, result *AssessmentResult) bool {
|
|
if result.RiskLevel == RiskLevelHIGH || result.RiskLevel == RiskLevelUNACCEPTABLE {
|
|
return true
|
|
}
|
|
if intake.DataTypes.Article9Data || intake.DataTypes.BiometricData {
|
|
return true
|
|
}
|
|
if intake.Purpose.Profiling && intake.DataTypes.PersonalData {
|
|
return true
|
|
}
|
|
for _, ctrl := range result.RequiredControls {
|
|
if ctrl.ID == "C_DSFA" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (e *PolicyEngine) determineTrainingAllowed(intake *UseCaseIntake) TrainingAllowed {
|
|
if intake.ModelUsage.Training && intake.DataTypes.PersonalData {
|
|
return TrainingNO
|
|
}
|
|
if intake.ModelUsage.Finetune && intake.DataTypes.PersonalData {
|
|
return TrainingCONDITIONAL
|
|
}
|
|
if intake.DataTypes.MinorData && (intake.ModelUsage.Training || intake.ModelUsage.Finetune) {
|
|
return TrainingNO
|
|
}
|
|
return TrainingYES
|
|
}
|
|
|
|
func (e *PolicyEngine) buildPatternRecommendations(patternPriority map[string]int) []PatternRecommendation {
|
|
type priorityPair struct {
|
|
id string
|
|
priority int
|
|
}
|
|
|
|
pairs := make([]priorityPair, 0, len(patternPriority))
|
|
for id, p := range patternPriority {
|
|
pairs = append(pairs, priorityPair{id, p})
|
|
}
|
|
sort.Slice(pairs, func(i, j int) bool {
|
|
return pairs[i].priority < pairs[j].priority
|
|
})
|
|
|
|
recommendations := make([]PatternRecommendation, 0, len(pairs))
|
|
for _, p := range pairs {
|
|
if pattern, ok := e.config.Patterns[p.id]; ok {
|
|
recommendations = append(recommendations, PatternRecommendation{
|
|
PatternID: pattern.ID,
|
|
Title: pattern.Title,
|
|
Description: pattern.Description,
|
|
Rationale: pattern.Benefit,
|
|
Priority: p.priority,
|
|
})
|
|
}
|
|
}
|
|
return recommendations
|
|
}
|