diff --git a/backend/internal/securityaudit/prompt_config.go b/backend/internal/securityaudit/prompt_config.go index c7c9f24ac5..92603d9ff4 100644 --- a/backend/internal/securityaudit/prompt_config.go +++ b/backend/internal/securityaudit/prompt_config.go @@ -64,20 +64,21 @@ type StorageEndpoint struct { } type storageConfig struct { - Enabled bool `json:"enabled"` - BlockingEnabled bool `json:"blocking_enabled"` - StorePassEvents bool `json:"store_pass_events"` - Strategy string `json:"strategy"` - WorkerCount int `json:"worker_count"` - QueueCapacity int `json:"queue_capacity"` - Scanners []string `json:"scanners"` - AllGroups bool `json:"all_groups"` - GroupIDs []int64 `json:"group_ids"` - Endpoints []StorageEndpoint `json:"endpoints"` - ConfigVersion int64 `json:"config_version"` - UpdatedAt time.Time `json:"updated_at"` - UpdatedBy int64 `json:"updated_by"` - ChangeSummary string `json:"change_summary"` + Enabled bool `json:"enabled"` + BlockingEnabled bool `json:"blocking_enabled"` + BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"` + StorePassEvents bool `json:"store_pass_events"` + Strategy string `json:"strategy"` + WorkerCount int `json:"worker_count"` + QueueCapacity int `json:"queue_capacity"` + Scanners []string `json:"scanners"` + AllGroups bool `json:"all_groups"` + GroupIDs []int64 `json:"group_ids"` + Endpoints []StorageEndpoint `json:"endpoints"` + ConfigVersion int64 `json:"config_version"` + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy int64 `json:"updated_by"` + ChangeSummary string `json:"change_summary"` } type ActiveEndpoint struct { @@ -98,21 +99,22 @@ type ActiveEndpoint struct { } type ActiveConfig struct { - RiskControlEnabled bool - Enabled bool - BlockingEnabled bool - StorePassEvents bool - Strategy string - WorkerCount int - QueueCapacity int - Scanners []string - AllGroups bool - GroupIDs []int64 - Endpoints []ActiveEndpoint - ConfigVersion int64 - UpdatedAt time.Time - UpdatedBy int64 - ChangeSummary string + RiskControlEnabled bool + Enabled bool + BlockingEnabled bool + BlockingLatestTurnOnly bool + StorePassEvents bool + Strategy string + WorkerCount int + QueueCapacity int + Scanners []string + AllGroups bool + GroupIDs []int64 + Endpoints []ActiveEndpoint + ConfigVersion int64 + UpdatedAt time.Time + UpdatedBy int64 + ChangeSummary string } type PublicEndpoint struct { @@ -129,21 +131,22 @@ type PublicEndpoint struct { } type PublicConfig struct { - Enabled bool `json:"enabled"` - BlockingEnabled bool `json:"blocking_enabled"` - StorePassEvents bool `json:"store_pass_events"` - EffectiveMode Mode `json:"effective_mode"` - Strategy string `json:"strategy"` - WorkerCount int `json:"worker_count"` - QueueCapacity int `json:"queue_capacity"` - Scanners []string `json:"scanners"` - AllGroups bool `json:"all_groups"` - GroupIDs []int64 `json:"group_ids"` - Endpoints []PublicEndpoint `json:"endpoints"` - ConfigVersion int64 `json:"config_version"` - UpdatedAt time.Time `json:"updated_at"` - UpdatedBy int64 `json:"updated_by"` - ChangeSummary string `json:"change_summary"` + Enabled bool `json:"enabled"` + BlockingEnabled bool `json:"blocking_enabled"` + BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"` + StorePassEvents bool `json:"store_pass_events"` + EffectiveMode Mode `json:"effective_mode"` + Strategy string `json:"strategy"` + WorkerCount int `json:"worker_count"` + QueueCapacity int `json:"queue_capacity"` + Scanners []string `json:"scanners"` + AllGroups bool `json:"all_groups"` + GroupIDs []int64 `json:"group_ids"` + Endpoints []PublicEndpoint `json:"endpoints"` + ConfigVersion int64 `json:"config_version"` + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy int64 `json:"updated_by"` + ChangeSummary string `json:"change_summary"` } type UpdateEndpoint struct { @@ -160,32 +163,34 @@ type UpdateEndpoint struct { } type UpdateConfigRequest struct { - ExpectedConfigVersion int64 `json:"expected_config_version" binding:"required"` - Enabled bool `json:"enabled"` - BlockingEnabled bool `json:"blocking_enabled"` - StorePassEvents bool `json:"store_pass_events"` - Strategy string `json:"strategy"` - WorkerCount int `json:"worker_count"` - QueueCapacity int `json:"queue_capacity"` - Scanners []string `json:"scanners"` - AllGroups bool `json:"all_groups"` - GroupIDs []int64 `json:"group_ids"` - Endpoints []UpdateEndpoint `json:"endpoints"` + ExpectedConfigVersion int64 `json:"expected_config_version" binding:"required"` + Enabled bool `json:"enabled"` + BlockingEnabled bool `json:"blocking_enabled"` + BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"` + StorePassEvents bool `json:"store_pass_events"` + Strategy string `json:"strategy"` + WorkerCount int `json:"worker_count"` + QueueCapacity int `json:"queue_capacity"` + Scanners []string `json:"scanners"` + AllGroups bool `json:"all_groups"` + GroupIDs []int64 `json:"group_ids"` + Endpoints []UpdateEndpoint `json:"endpoints"` } func DefaultStorageConfig() storageConfig { return storageConfig{ - Enabled: false, - BlockingEnabled: false, - StorePassEvents: false, - Strategy: "priority", - WorkerCount: DefaultWorkerCount, - QueueCapacity: DefaultQueueCapacity, - Scanners: append([]string(nil), AllScannerIDs...), - AllGroups: true, - GroupIDs: []int64{}, - Endpoints: []StorageEndpoint{}, - ConfigVersion: 1, + Enabled: false, + BlockingEnabled: false, + BlockingLatestTurnOnly: false, + StorePassEvents: false, + Strategy: "priority", + WorkerCount: DefaultWorkerCount, + QueueCapacity: DefaultQueueCapacity, + Scanners: append([]string(nil), AllScannerIDs...), + AllGroups: true, + GroupIDs: []int64{}, + Endpoints: []StorageEndpoint{}, + ConfigVersion: 1, } } @@ -407,7 +412,7 @@ func PublicFromStorage(cfg storageConfig, riskControlEnabled bool, invalidTokenE } active := ActiveConfig{RiskControlEnabled: riskControlEnabled, Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled} return PublicConfig{ - Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled, StorePassEvents: cfg.StorePassEvents, + Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled, BlockingLatestTurnOnly: cfg.BlockingLatestTurnOnly, StorePassEvents: cfg.StorePassEvents, EffectiveMode: active.EffectiveMode(), Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount, QueueCapacity: cfg.QueueCapacity, Scanners: scanners, AllGroups: cfg.AllGroups, GroupIDs: groupIDs, Endpoints: endpoints, ConfigVersion: cfg.ConfigVersion, @@ -418,7 +423,8 @@ func PublicFromStorage(cfg storageConfig, riskControlEnabled bool, invalidTokenE func ActiveFromStorage(cfg storageConfig, riskControlEnabled bool, encryptor SecretEncryptor) (ActiveConfig, error) { active := ActiveConfig{ RiskControlEnabled: riskControlEnabled, Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled, - StorePassEvents: cfg.StorePassEvents, Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount, + BlockingLatestTurnOnly: cfg.BlockingLatestTurnOnly, + StorePassEvents: cfg.StorePassEvents, Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount, QueueCapacity: cfg.QueueCapacity, Scanners: append([]string(nil), cfg.Scanners...), AllGroups: cfg.AllGroups, GroupIDs: append([]int64(nil), cfg.GroupIDs...), ConfigVersion: cfg.ConfigVersion, UpdatedAt: cfg.UpdatedAt, UpdatedBy: cfg.UpdatedBy, ChangeSummary: cfg.ChangeSummary, @@ -454,15 +460,16 @@ func ActiveFromStorage(cfg storageConfig, riskControlEnabled bool, encryptor Sec func changeSummary(cfg storageConfig) string { summary := struct { - Enabled bool `json:"enabled"` - BlockingEnabled bool `json:"blocking_enabled"` - StorePassEvents bool `json:"store_pass_events"` - EndpointCount int `json:"endpoint_count"` - ScannerCount int `json:"scanner_count"` - AllGroups bool `json:"all_groups"` - GroupCount int `json:"group_count"` - GroupHash string `json:"group_hash"` - }{cfg.Enabled, cfg.BlockingEnabled, cfg.StorePassEvents, len(cfg.Endpoints), len(cfg.Scanners), cfg.AllGroups, len(cfg.GroupIDs), ""} + Enabled bool `json:"enabled"` + BlockingEnabled bool `json:"blocking_enabled"` + BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"` + StorePassEvents bool `json:"store_pass_events"` + EndpointCount int `json:"endpoint_count"` + ScannerCount int `json:"scanner_count"` + AllGroups bool `json:"all_groups"` + GroupCount int `json:"group_count"` + GroupHash string `json:"group_hash"` + }{cfg.Enabled, cfg.BlockingEnabled, cfg.BlockingLatestTurnOnly, cfg.StorePassEvents, len(cfg.Endpoints), len(cfg.Scanners), cfg.AllGroups, len(cfg.GroupIDs), ""} rawGroups, _ := json.Marshal(cfg.GroupIDs) digest := sha256.Sum256(rawGroups) summary.GroupHash = hex.EncodeToString(digest[:]) diff --git a/backend/internal/securityaudit/prompt_config_store.go b/backend/internal/securityaudit/prompt_config_store.go index b832323e8c..2ce102296a 100644 --- a/backend/internal/securityaudit/prompt_config_store.go +++ b/backend/internal/securityaudit/prompt_config_store.go @@ -335,7 +335,7 @@ func (m *ConfigManager) buildNextStorage(current storageConfig, req UpdateConfig currentByID[endpoint.ID] = endpoint } next := storageConfig{ - Enabled: req.Enabled, BlockingEnabled: req.BlockingEnabled, StorePassEvents: req.StorePassEvents, + Enabled: req.Enabled, BlockingEnabled: req.BlockingEnabled, BlockingLatestTurnOnly: req.BlockingLatestTurnOnly, StorePassEvents: req.StorePassEvents, Strategy: strings.TrimSpace(req.Strategy), WorkerCount: req.WorkerCount, QueueCapacity: req.QueueCapacity, Scanners: append([]string(nil), req.Scanners...), AllGroups: req.AllGroups, GroupIDs: append([]int64(nil), req.GroupIDs...), diff --git a/backend/internal/securityaudit/prompt_config_test.go b/backend/internal/securityaudit/prompt_config_test.go index 96cc5a5533..48175947c2 100644 --- a/backend/internal/securityaudit/prompt_config_test.go +++ b/backend/internal/securityaudit/prompt_config_test.go @@ -32,6 +32,7 @@ func TestDefaultConfigIsOff(t *testing.T) { storage, err := ParseStorageConfig("") require.NoError(t, err) require.False(t, storage.Enabled) + require.False(t, storage.BlockingLatestTurnOnly) active, err := ActiveFromStorage(storage, true, prefixEncryptor{}) require.NoError(t, err) require.Equal(t, ModeOff, active.EffectiveMode()) @@ -42,6 +43,28 @@ func TestDefaultConfigIsOff(t *testing.T) { require.Contains(t, string(publicJSON), `"endpoints":[]`) } +func TestBlockingLatestTurnOnlyConfigRoundTrip(t *testing.T) { + manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: true} + request := UpdateConfigRequest{ + ExpectedConfigVersion: 1, Enabled: true, BlockingEnabled: true, BlockingLatestTurnOnly: true, + Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"pii"}, AllGroups: true, + Endpoints: []UpdateEndpoint{{ + ID: "guard-1", Name: "Guard", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", + Model: DefaultGuardModel, TimeoutMS: 1000, InputLimit: 1000, Enabled: true, + }}, + } + next, err := manager.buildNextStorage(DefaultStorageConfig(), request, 9) + require.NoError(t, err) + require.True(t, next.BlockingLatestTurnOnly) + require.Contains(t, changeSummary(next), `"blocking_latest_turn_only":true`) + + active, err := ActiveFromStorage(next, true, prefixEncryptor{}) + require.NoError(t, err) + require.True(t, active.BlockingLatestTurnOnly) + public := PublicFromStorage(next, true, nil) + require.True(t, public.BlockingLatestTurnOnly) +} + func TestConfigRejectsBlockingWithoutAudit(t *testing.T) { storage := DefaultStorageConfig() storage.BlockingEnabled = true diff --git a/backend/internal/securityaudit/prompt_handler.go b/backend/internal/securityaudit/prompt_handler.go index 24284aa540..342f3a5461 100644 --- a/backend/internal/securityaudit/prompt_handler.go +++ b/backend/internal/securityaudit/prompt_handler.go @@ -228,7 +228,8 @@ func configAuditFields(request UpdateConfigRequest, saved *PublicConfig) map[str } return map[string]any{ "enabled": request.Enabled, "blocking_enabled": request.BlockingEnabled, - "config_version": version, "endpoint_count": len(request.Endpoints), + "blocking_latest_turn_only": request.BlockingLatestTurnOnly, + "config_version": version, "endpoint_count": len(request.Endpoints), "scanner_count": len(request.Scanners), "all_groups": request.AllGroups, "group_count": len(request.GroupIDs), } diff --git a/backend/internal/securityaudit/prompt_service.go b/backend/internal/securityaudit/prompt_service.go index 2d9d6605e0..a8cefcd648 100644 --- a/backend/internal/securityaudit/prompt_service.go +++ b/backend/internal/securityaudit/prompt_service.go @@ -156,7 +156,7 @@ func (s *PromptService) Evaluate(ctx context.Context, req Request) (*PromptDecis if cfg.EffectiveMode() != ModeBlocking || !cfg.IncludesGroup(req.GroupID) { return &PromptDecision{Kind: DecisionAllow, AllowNextStage: true}, nil } - snapshot, err := ExtractPromptSnapshot(req) + snapshot, err := ExtractBlockingPromptSnapshot(req, cfg.BlockingLatestTurnOnly) if errors.Is(err, ErrNoPromptText) { return &PromptDecision{Kind: DecisionAllow, AllowNextStage: true}, nil } diff --git a/backend/internal/securityaudit/prompt_service_test.go b/backend/internal/securityaudit/prompt_service_test.go index 5034b31af6..55edf36911 100644 --- a/backend/internal/securityaudit/prompt_service_test.go +++ b/backend/internal/securityaudit/prompt_service_test.go @@ -68,6 +68,25 @@ func TestPromptServiceStartReportsDependencyFailureWithoutPanic(t *testing.T) { require.NoError(t, service.Shutdown(ctx)) } +func TestPromptServiceBlockingLatestTurnOnlyUsesNarrowSnapshot(t *testing.T) { + seen := make([]string, 0, 2) + evaluator := newGuardEvaluator(PromptScannerFunc(func(_ context.Context, _ ActiveEndpoint, chunk string, _ []string) (*NormalizedResult, error) { + seen = append(seen, chunk) + return &NormalizedResult{Decision: EventPass, RiskLevel: RiskLow, Action: ActionAllow, ScannerScores: map[string]float64{}, ScannerEvidence: map[string]string{}}, nil + }), nil, NewAtomicMetrics(), 2, 2) + service := &PromptService{ + config: &fakeConfigStore{active: true, cfg: ActiveConfig{ + RiskControlEnabled: true, Enabled: true, BlockingEnabled: true, BlockingLatestTurnOnly: true, AllGroups: true, + Scanners: AllScannerIDs, Endpoints: []ActiveEndpoint{{ID: "guard-1", Enabled: true, TimeoutMS: 1000, InputLimit: 4096}}, + }}, + evaluator: evaluator, + } + decision, err := service.Evaluate(context.Background(), Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"user","content":"older user input"},{"role":"assistant","content":"previous output"},{"role":"user","content":"latest user input"}]}`)}) + require.NoError(t, err) + require.Equal(t, DecisionAllow, decision.Kind) + require.Equal(t, []string{"latest user input", "previous output"}, seen) +} + func TestPromptServiceRejectsInvalidDeleteConfirmationClaims(t *testing.T) { now := time.Date(2026, 7, 16, 10, 0, 0, 0, time.UTC) start, end := now.Add(-time.Hour), now.Add(time.Hour) diff --git a/backend/internal/securityaudit/prompt_snapshot.go b/backend/internal/securityaudit/prompt_snapshot.go index 2d38a877ff..c3219366fa 100644 --- a/backend/internal/securityaudit/prompt_snapshot.go +++ b/backend/internal/securityaudit/prompt_snapshot.go @@ -26,15 +26,30 @@ const promptAuditPrioritySeparator = "\x00SUB2API_PROMPT_AUDIT_PRIORITY_END\x00" type promptSegment struct { text string user bool + role string } func ExtractPromptSnapshot(req Request) (PromptSnapshot, error) { + return extractPromptSnapshot(req, false) +} + +// ExtractBlockingPromptSnapshot builds the narrow, low-latency blocking input +// when configured. Asynchronous auditing always uses ExtractPromptSnapshot so +// the complete client-controlled transcript is retained for review. +func ExtractBlockingPromptSnapshot(req Request, latestTurnOnly bool) (PromptSnapshot, error) { + return extractPromptSnapshot(req, latestTurnOnly) +} + +func extractPromptSnapshot(req Request, latestTurnOnly bool) (PromptSnapshot, error) { var document any if err := json.Unmarshal(req.Body, &document); err != nil { return PromptSnapshot{}, errors.New("prompt audit request JSON is invalid") } extracted := extractProtocolSegments(req.Protocol, document) segments := normalizeSegmentsLatestUserFirst(extracted) + if latestTurnOnly { + segments = blockingSegmentsLatestUserAndPreviousOutput(extracted) + } if len(segments) == 0 { return PromptSnapshot{}, ErrNoPromptText } @@ -138,7 +153,7 @@ func extractMessages(value any, wantedRoles ...string) []promptSegment { } texts := contentTexts(message["content"]) for _, text := range texts { - result = append(result, promptSegment{text: text, user: role == "user"}) + result = append(result, promptSegment{text: text, user: role == "user", role: role}) } } return result @@ -148,7 +163,7 @@ func extractInstructions(value any) []promptSegment { switch typed := value.(type) { case string: if text := strings.TrimSpace(typed); text != "" { - return []promptSegment{{text: text}} + return []promptSegment{{text: text, role: "system"}} } case []any: return systemPromptSegments(contentTexts(typed)) @@ -162,7 +177,7 @@ func extractAnthropicSystem(value any) []promptSegment { switch typed := value.(type) { case string: if text := strings.TrimSpace(typed); text != "" { - return []promptSegment{{text: text}} + return []promptSegment{{text: text, role: "system"}} } case []any: return systemPromptSegments(contentTexts(typed)) @@ -175,13 +190,13 @@ func extractAnthropicSystem(value any) []promptSegment { func extractResponses(value any) []promptSegment { switch typed := value.(type) { case string: - return []promptSegment{{text: typed, user: true}} + return []promptSegment{{text: typed, user: true, role: "user"}} case []any: result := make([]promptSegment, 0, len(typed)) for _, item := range typed { switch entry := item.(type) { case string: - result = append(result, promptSegment{text: entry, user: true}) + result = append(result, promptSegment{text: entry, user: true, role: "user"}) case map[string]any: role := strings.ToLower(stringValue(entry["role"])) if role != "" && !isClientInstructionRole(role) { @@ -189,10 +204,10 @@ func extractResponses(value any) []promptSegment { } if content, exists := entry["content"]; exists { for _, text := range contentTexts(content) { - result = append(result, promptSegment{text: text, user: role == "" || role == "user"}) + result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role}) } } else if text := stringValue(entry["text"]); text != "" { - result = append(result, promptSegment{text: text, user: role == "" || role == "user"}) + result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role}) } } } @@ -241,7 +256,7 @@ func extractGemini(value any) []promptSegment { for _, part := range parts { if object, ok := part.(map[string]any); ok { if text := stringValue(object["text"]); text != "" { - result = append(result, promptSegment{text: text, user: role == "" || role == "user"}) + result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role}) } } } @@ -278,7 +293,7 @@ func extractGeminiSystemInstruction(value any) []promptSegment { switch typed := value.(type) { case string: if text := strings.TrimSpace(typed); text != "" { - return []promptSegment{{text: text}} + return []promptSegment{{text: text, role: "system"}} } case map[string]any: if parts, ok := typed["parts"].([]any); ok { @@ -286,7 +301,7 @@ func extractGeminiSystemInstruction(value any) []promptSegment { for _, part := range parts { if object, ok := part.(map[string]any); ok { if text := stringValue(object["text"]); text != "" { - result = append(result, promptSegment{text: text}) + result = append(result, promptSegment{text: text, role: "system"}) } } } @@ -297,6 +312,7 @@ func extractGeminiSystemInstruction(value any) []promptSegment { segments := extractGemini(typed) for index := range segments { segments[index].user = false + segments[index].role = "system" } return segments } @@ -312,7 +328,7 @@ func extractGeminiInstances(value any) []promptSegment { for _, item := range instances { if instance, ok := item.(map[string]any); ok { if prompt := stringValue(instance["prompt"]); prompt != "" { - result = append(result, promptSegment{text: prompt, user: true}) + result = append(result, promptSegment{text: prompt, user: true, role: "user"}) } } } @@ -420,19 +436,13 @@ func contentTexts(value any) []string { } func normalizeSegmentsLatestUserFirst(values []promptSegment) []string { - normalized := make([]promptSegment, 0, len(values)) - for _, value := range values { - value.text = strings.TrimSpace(value.text) - if value.text != "" { - normalized = append(normalized, value) - } - } + normalized := normalizedPromptSegments(values) if len(normalized) == 0 { return nil } priorityIndex := len(normalized) - 1 for index := len(normalized) - 1; index >= 0; index-- { - if normalized[index].user { + if isUserSegment(normalized[index]) { priorityIndex = index break } @@ -447,6 +457,85 @@ func normalizeSegmentsLatestUserFirst(values []promptSegment) []string { return result } +// blockingSegmentsLatestUserAndPreviousOutput limits synchronous guard input to +// the current user turn and the nearest preceding assistant/model turn. It is +// deliberately opt-in because full transcript scanning remains stronger at +// finding client-controlled content placed in older or non-user messages. +func blockingSegmentsLatestUserAndPreviousOutput(values []promptSegment) []string { + normalized := normalizedPromptSegments(values) + latestUserStart := latestUserSegmentStart(normalized) + if latestUserStart < 0 { + // A request without user content cannot be narrowed safely. Preserve the + // established full-snapshot behavior for unusual protocol payloads. + return normalizeSegmentsLatestUserFirst(values) + } + latestUserEnd := latestUserStart + for latestUserEnd < len(normalized) && isUserSegment(normalized[latestUserEnd]) { + latestUserEnd++ + } + currentUserText := make([]string, 0, latestUserEnd-latestUserStart) + for _, segment := range normalized[latestUserStart:latestUserEnd] { + currentUserText = append(currentUserText, segment.text) + } + // A single client turn may have several text content parts. Keep it in one + // priority segment so every part of the latest input is scanned before the + // prior output begins. + selected := []promptSegment{{text: strings.Join(currentUserText, "\n\n"), user: true, role: "user"}} + for index := latestUserStart - 1; index >= 0; index-- { + if !isAssistantOutputSegment(normalized[index]) { + continue + } + start := index + for start > 0 && isAssistantOutputSegment(normalized[start-1]) { + start-- + } + selected = append(selected, normalized[start:index+1]...) + break + } + return promptSegmentTexts(selected) +} + +func normalizedPromptSegments(values []promptSegment) []promptSegment { + normalized := make([]promptSegment, 0, len(values)) + for _, value := range values { + value.text = strings.TrimSpace(value.text) + if value.text != "" { + normalized = append(normalized, value) + } + } + return normalized +} + +func latestUserSegmentStart(values []promptSegment) int { + latest := -1 + for index := len(values) - 1; index >= 0; index-- { + if isUserSegment(values[index]) { + latest = index + break + } + } + for latest > 0 && isUserSegment(values[latest-1]) { + latest-- + } + return latest +} + +func isUserSegment(segment promptSegment) bool { + return segment.user || segment.role == "user" +} + +func isAssistantOutputSegment(segment promptSegment) bool { + return segment.role == "assistant" || segment.role == "model" +} + +func promptSegmentTexts(values []promptSegment) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + result = append(result, value.text) + } + return result +} + func buildPrioritizedScanText(segments []string) (scanText string, metadataText string) { metadataText = strings.Join(segments, "\n\n") if len(segments) <= 1 { @@ -458,7 +547,7 @@ func buildPrioritizedScanText(segments []string) (scanText string, metadataText func promptSegmentsForRole(texts []string, role string) []promptSegment { result := make([]promptSegment, 0, len(texts)) for _, text := range texts { - result = append(result, promptSegment{text: text, user: role == "" || role == "user"}) + result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role}) } return result } diff --git a/backend/internal/securityaudit/prompt_snapshot_test.go b/backend/internal/securityaudit/prompt_snapshot_test.go index ceff3756e2..4802138cf8 100644 --- a/backend/internal/securityaudit/prompt_snapshot_test.go +++ b/backend/internal/securityaudit/prompt_snapshot_test.go @@ -293,6 +293,65 @@ func TestPromptSnapshotIncludesClientControlledInstructions(t *testing.T) { } } +func TestBlockingPromptSnapshotLimitsInputToLatestUserAndPreviousOutput(t *testing.T) { + tests := []struct { + name, protocol, body, want string + omitted []string + }{ + { + name: "chat keeps multipart latest user and prior assistant", + protocol: "openai_chat_completions", + body: `{"messages":[ + {"role":"system","content":"system instruction"}, + {"role":"user","content":"older user input"}, + {"role":"assistant","content":"older assistant output"}, + {"role":"tool","content":"tool payload"}, + {"role":"assistant","content":"previous assistant output"}, + {"role":"user","content":[{"type":"text","text":"latest user first part"},{"type":"text","text":"latest user second part"}]} + ]}`, + want: "latest user first part\n\nlatest user second part" + promptAuditPrioritySeparator + "previous assistant output", + omitted: []string{"system instruction", "older user input", "older assistant output", "tool payload"}, + }, + { + name: "gemini keeps prior model output", + protocol: "gemini", + body: `{"systemInstruction":{"parts":[{"text":"system instruction"}]},"contents":[ + {"role":"user","parts":[{"text":"older user input"}]}, + {"role":"model","parts":[{"text":"previous model output"}]}, + {"role":"user","parts":[{"text":"latest user input"}]} + ]}`, + want: "latest user input" + promptAuditPrioritySeparator + "previous model output", + omitted: []string{"system instruction", "older user input"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + snapshot, err := ExtractBlockingPromptSnapshot(Request{Protocol: tt.protocol, Body: []byte(tt.body)}, true) + require.NoError(t, err) + require.Equal(t, tt.want, snapshot.ScanText) + for _, omitted := range tt.omitted { + require.NotContains(t, snapshot.ScanText, omitted) + } + }) + } +} + +func TestBlockingPromptSnapshotPreservesFullScopeByDefaultAndWithoutUserInput(t *testing.T) { + req := Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"user","content":"older user input"},{"role":"assistant","content":"previous output"},{"role":"user","content":"latest user input"}]}`)} + full, err := ExtractPromptSnapshot(req) + require.NoError(t, err) + defaultBlocking, err := ExtractBlockingPromptSnapshot(req, false) + require.NoError(t, err) + require.Equal(t, full, defaultBlocking) + + noUser := Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"assistant","content":"assistant output"}]}`)} + fullWithoutUser, err := ExtractPromptSnapshot(noUser) + require.NoError(t, err) + narrowWithoutUser, err := ExtractBlockingPromptSnapshot(noUser, true) + require.NoError(t, err) + require.Equal(t, fullWithoutUser, narrowWithoutUser) +} + func TestBuildPromptPreviewWithholdsMajorityOfOrdinaryText(t *testing.T) { prompt := strings.Repeat("机密业务提示词内容", 40) preview := BuildPromptPreview(prompt, DefaultPromptPreviewMaxRunes) diff --git a/frontend/src/features/prompt-audit/PromptAuditView.vue b/frontend/src/features/prompt-audit/PromptAuditView.vue index 8ea9c6cc9d..ee72895215 100644 --- a/frontend/src/features/prompt-audit/PromptAuditView.vue +++ b/frontend/src/features/prompt-audit/PromptAuditView.vue @@ -95,6 +95,7 @@
+
diff --git a/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts b/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts index f271db9e38..c68fa06f4f 100644 --- a/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts +++ b/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts @@ -19,7 +19,7 @@ vi.mock('vue-i18n', async () => { }) const baseConfig = (): PromptAuditConfig => ({ - enabled: true, blocking_enabled: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority', + enabled: true, blocking_enabled: false, blocking_latest_turn_only: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority', worker_count: 4, queue_capacity: 100, scanners: SCANNER_CATALOG.map((item) => item.id), all_groups: true, group_ids: [], endpoints: [{ id: 'guard-1', name: 'Guard One', protocol: 'openai_compatible', base_url: 'http://127.0.0.1:8000', model: 'guard-model', timeout_ms: 3000, input_limit: 4000, enabled: true, has_token: true, token_status: 'configured' }], config_version: 7, updated_at: '2026-07-16T00:00:00Z', updated_by: 1, change_summary: '{}', @@ -126,10 +126,13 @@ describe('PromptAuditView', () => { expect(wrapper.find('[data-test="confirm"]').exists()).toBe(true) await wrapper.get('[data-test="confirm-action"]').trigger('click') expect(wrapper.get('[data-test="blocking-toggle"]').attributes('aria-checked')).toBe('true') + await wrapper.get('[data-test="blocking-latest-turn-only-toggle"]').trigger('click') + expect(wrapper.get('[data-test="blocking-latest-turn-only-toggle"]').attributes('aria-checked')).toBe('true') await wrapper.get('[data-test="enabled-toggle"]').trigger('click') expect(wrapper.get('[data-test="enabled-toggle"]').attributes('aria-checked')).toBe('false') expect(wrapper.get('[data-test="blocking-toggle"]').attributes('aria-checked')).toBe('false') expect(wrapper.get('[data-test="blocking-toggle"]').attributes()).toHaveProperty('disabled') + expect(wrapper.get('[data-test="blocking-latest-turn-only-toggle"]').attributes()).toHaveProperty('disabled') }) it('clears plaintext token state after a successful save', async () => { @@ -175,7 +178,7 @@ describe('PromptAuditView', () => { await flushPromises() await wrapper.get('[data-test="tab-config"]').trigger('click') const switches = wrapper.findAll('[role="switch"]') - expect(switches).toHaveLength(3) + expect(switches).toHaveLength(4) expect(switches.every((item) => Boolean(item.attributes('aria-label')))).toBe(true) expect(wrapper.html()).toContain('fixed inset-x-0 bottom-0') expect(wrapper.html()).toContain('flex-wrap') diff --git a/frontend/src/features/prompt-audit/__tests__/components.spec.ts b/frontend/src/features/prompt-audit/__tests__/components.spec.ts index e9d679527a..aa3ead12d5 100644 --- a/frontend/src/features/prompt-audit/__tests__/components.spec.ts +++ b/frontend/src/features/prompt-audit/__tests__/components.spec.ts @@ -67,7 +67,7 @@ describe('Prompt Audit components', () => { it('supports group search, stale configured groups, nine scanners, and bounded worker inputs', async () => { const draft: PromptAuditDraft = { - enabled: true, blocking_enabled: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority', + enabled: true, blocking_enabled: false, blocking_latest_turn_only: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority', worker_count: 4, queue_capacity: 100, scanners: SCANNER_CATALOG.map((item) => item.id), all_groups: false, group_ids: [1, 99], endpoints: [endpoint()], config_version: 1, updated_at: '', updated_by: 0, change_summary: '', } diff --git a/frontend/src/features/prompt-audit/__tests__/viewModel.spec.ts b/frontend/src/features/prompt-audit/__tests__/viewModel.spec.ts index f4390466a8..06cf6b4635 100644 --- a/frontend/src/features/prompt-audit/__tests__/viewModel.spec.ts +++ b/frontend/src/features/prompt-audit/__tests__/viewModel.spec.ts @@ -13,6 +13,7 @@ import { const config = (): PromptAuditConfig => ({ enabled: true, blocking_enabled: false, + blocking_latest_turn_only: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority', @@ -56,6 +57,12 @@ describe('Prompt Audit view model', () => { expect(buildUpdateRequest(draft).endpoints[0]).toMatchObject({ token: undefined, clear_token: true }) }) + it('includes the optional narrow blocking scope in the update payload', () => { + const draft = configToDraft(config()) + draft.blocking_latest_turn_only = true + expect(buildUpdateRequest(draft)).toMatchObject({ blocking_latest_turn_only: true }) + }) + it('tracks dirty state from the full normalized save payload', () => { const original = configToDraft(config()) const changed = configToDraft(config()) diff --git a/frontend/src/features/prompt-audit/types.ts b/frontend/src/features/prompt-audit/types.ts index ea00cb8f7e..aeeb16b96f 100644 --- a/frontend/src/features/prompt-audit/types.ts +++ b/frontend/src/features/prompt-audit/types.ts @@ -23,6 +23,7 @@ export interface PromptAuditEndpointDraft extends PromptAuditEndpoint { export interface PromptAuditConfig { enabled: boolean blocking_enabled: boolean + blocking_latest_turn_only: boolean store_pass_events: boolean effective_mode: PromptAuditMode strategy: 'priority' @@ -46,6 +47,7 @@ export interface PromptAuditUpdateRequest { expected_config_version: number enabled: boolean blocking_enabled: boolean + blocking_latest_turn_only: boolean store_pass_events: boolean strategy: 'priority' worker_count: number diff --git a/frontend/src/features/prompt-audit/viewModel.ts b/frontend/src/features/prompt-audit/viewModel.ts index f5a06760e1..2762a94b57 100644 --- a/frontend/src/features/prompt-audit/viewModel.ts +++ b/frontend/src/features/prompt-audit/viewModel.ts @@ -62,6 +62,7 @@ export function buildUpdateRequest(draft: PromptAuditDraft): PromptAuditUpdateRe expected_config_version: draft.config_version, enabled: draft.enabled, blocking_enabled: draft.enabled && draft.blocking_enabled, + blocking_latest_turn_only: draft.blocking_latest_turn_only, store_pass_events: draft.store_pass_events, strategy: 'priority', worker_count: Number(draft.worker_count), diff --git a/frontend/src/i18n/locales/en/admin/promptAudit.ts b/frontend/src/i18n/locales/en/admin/promptAudit.ts index c7263bb0c1..3206cfbbec 100644 --- a/frontend/src/i18n/locales/en/admin/promptAudit.ts +++ b/frontend/src/i18n/locales/en/admin/promptAudit.ts @@ -55,7 +55,7 @@ export default { searchGroups: 'Search groups', noGroups: 'No matching groups', missingGroups: 'Configured IDs for groups that no longer exist', selectedCount: '{count} groups selected', scanners: 'Qwen3Guard input-risk categories', workerCount: 'Worker count', queueCapacity: 'Persistent queue capacity', strategy: 'Node strategy', strategyHint: 'Try nodes in configuration order and fail over when allowed.', }, - saveBar: { enabled: 'Enable prompt audit', blocking: 'Synchronous blocking', storePass: 'Store safe events', dirty: 'Unsaved changes', synced: 'Configuration synced' }, + saveBar: { enabled: 'Enable prompt audit', blocking: 'Synchronous blocking', blockingLatestTurnOnly: 'Only latest input and prior output', storePass: 'Store safe events', dirty: 'Unsaved changes', synced: 'Configuration synced' }, blockingConfirm: { title: 'Enable synchronous blocking?', message: 'Applicable requests wait for Guard before account selection, billing, or upstream access. Block, unavailable Guard, and invalid responses all prevent upstream access.', diff --git a/frontend/src/i18n/locales/zh/admin/promptAudit.ts b/frontend/src/i18n/locales/zh/admin/promptAudit.ts index 8e71f12021..1177c2de14 100644 --- a/frontend/src/i18n/locales/zh/admin/promptAudit.ts +++ b/frontend/src/i18n/locales/zh/admin/promptAudit.ts @@ -55,7 +55,7 @@ export default { searchGroups: '搜索分组', noGroups: '没有匹配分组', missingGroups: '配置中包含已删除的分组 ID', selectedCount: '已选择 {count} 个分组', scanners: 'Qwen3Guard 输入风险分类', workerCount: 'Worker 数量', queueCapacity: '持久队列容量', strategy: '节点策略', strategyHint: '按配置顺序优先尝试,必要时故障切换。', }, - saveBar: { enabled: '启用提示词审计', blocking: '同步阻止', storePass: '保存安全事件', dirty: '有未保存的更改', synced: '配置已同步' }, + saveBar: { enabled: '启用提示词审计', blocking: '同步阻止', blockingLatestTurnOnly: '仅审最新输入和上一轮输出', storePass: '保存安全事件', dirty: '有未保存的更改', synced: '配置已同步' }, blockingConfirm: { title: '开启同步阻止?', message: '适用请求会在账号选择、计费和访问上游之前等待 Guard。命中 Block、Guard 不可用或响应非法时,请求都不会访问上游。',