diff --git a/backend/internal/securityaudit/prompt_config.go b/backend/internal/securityaudit/prompt_config.go index 3ad690d4d4..e7238b517f 100644 --- a/backend/internal/securityaudit/prompt_config.go +++ b/backend/internal/securityaudit/prompt_config.go @@ -44,7 +44,7 @@ type ConfigStore interface { // It must stay false when blocking is not intended, even if config is // untrusted—otherwise default-off deployments fail closed for all traffic. BlockingActivationDegraded() bool - Public() PublicConfig + Public() (PublicConfig, error) Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) RuntimeState() (expected int64, active int64, loadedAt *time.Time, loadError string) Encrypt(value string) (string, error) diff --git a/backend/internal/securityaudit/prompt_config_store.go b/backend/internal/securityaudit/prompt_config_store.go index 7bc70f4c0b..70d17da943 100644 --- a/backend/internal/securityaudit/prompt_config_store.go +++ b/backend/internal/securityaudit/prompt_config_store.go @@ -194,15 +194,15 @@ func (m *ConfigManager) markUntrustedIfNoActiveSnapshot() { } } -func (m *ConfigManager) Public() PublicConfig { +func (m *ConfigManager) Public() (PublicConfig, error) { if m == nil { - return PublicFromStorage(DefaultStorageConfig(), false) + return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用") } snapshot := m.snapshot.Load() if snapshot == nil { - return PublicFromStorage(DefaultStorageConfig(), false) + return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用") } - return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled) + return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled), nil } func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) { diff --git a/backend/internal/securityaudit/prompt_config_test.go b/backend/internal/securityaudit/prompt_config_test.go index 22ea50a35b..b71fca1d57 100644 --- a/backend/internal/securityaudit/prompt_config_test.go +++ b/backend/internal/securityaudit/prompt_config_test.go @@ -56,6 +56,57 @@ func TestConfigRuntimeLoadErrorIsStableBoundedAndSecretFree(t *testing.T) { require.LessOrEqual(t, len([]rune(message)), 160) } +func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) { + t.Run("absent persisted setting is legitimate default", func(t *testing.T) { + manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{ + SettingKeyPromptAuditConfig: "", + SettingKeyRiskControl: "false", + }}, nil, prefixEncryptor{}) + require.NoError(t, manager.Reload(context.Background())) + + public, err := manager.Public() + require.NoError(t, err) + require.Equal(t, int64(1), public.ConfigVersion) + require.False(t, public.Enabled) + }) + + t.Run("persisted config activation failure is unavailable", func(t *testing.T) { + const canary = "persisted-token-canary" + manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{ + SettingKeyPromptAuditConfig: `{"enabled":true,"config_version":9,"endpoints":[{"token_ciphertext":"` + canary + `"}]}`, + SettingKeyRiskControl: "true", + }}, nil, prefixEncryptor{}) + require.Error(t, manager.Reload(context.Background())) + + public, err := manager.Public() + require.Error(t, err) + require.Empty(t, public) + require.Equal(t, ErrorCodeConfigUnavailable, infraerrors.Reason(err)) + require.NotContains(t, err.Error(), canary) + }) + + t.Run("reload failure preserves last successfully loaded snapshot", func(t *testing.T) { + storage := DefaultStorageConfig() + storage.ConfigVersion = 4 + storage.ChangeSummary = "trusted snapshot" + raw, err := json.Marshal(storage) + require.NoError(t, err) + repository := &switchableSettingRepository{staticSettingRepository: staticSettingRepository{values: map[string]string{ + SettingKeyPromptAuditConfig: string(raw), + SettingKeyRiskControl: "false", + }}} + manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}) + require.NoError(t, manager.Reload(context.Background())) + repository.loadErr = errors.New("settings unavailable") + require.Error(t, manager.Reload(context.Background())) + + public, err := manager.Public() + require.NoError(t, err) + require.Equal(t, int64(4), public.ConfigVersion) + require.Equal(t, "trusted snapshot", public.ChangeSummary) + }) +} + func TestBuildNextStoragePreserveReplaceAndClearToken(t *testing.T) { manager := &ConfigManager{encryptor: prefixEncryptor{}} current := DefaultStorageConfig() @@ -137,6 +188,18 @@ func (errorSettingRepository) GetMultiple(context.Context, []string) (map[string return nil, errors.New("settings unavailable") } +type switchableSettingRepository struct { + staticSettingRepository + loadErr error +} + +func (r *switchableSettingRepository) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + if r.loadErr != nil { + return nil, r.loadErr + } + return r.staticSettingRepository.GetMultiple(ctx, keys) +} + func TestConfigManagerStartupLoadFailureDoesNotBlockWhenBlockingNotIntended(t *testing.T) { // Settings unavailable and no prior blocking intent: stay ModeOff so the // gateway remains usable and admins can still disable/configure Prompt Audit. diff --git a/backend/internal/securityaudit/prompt_handler.go b/backend/internal/securityaudit/prompt_handler.go index b2d5a51a67..24284aa540 100644 --- a/backend/internal/securityaudit/prompt_handler.go +++ b/backend/internal/securityaudit/prompt_handler.go @@ -13,7 +13,7 @@ import ( ) type PromptAdminService interface { - GetConfig() PublicConfig + GetConfig() (PublicConfig, error) SaveConfig(context.Context, UpdateConfigRequest, int64) (PublicConfig, error) Probe(context.Context, ProbeRequest) ProbeResult Runtime(context.Context) RuntimeSnapshot @@ -31,7 +31,14 @@ func NewPromptAdminHandler(service PromptAdminService) *PromptAdminHandler { return &PromptAdminHandler{service: service} } -func (h *PromptAdminHandler) GetConfig(c *gin.Context) { response.Success(c, h.service.GetConfig()) } +func (h *PromptAdminHandler) GetConfig(c *gin.Context) { + config, err := h.service.GetConfig() + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, config) +} func (h *PromptAdminHandler) UpdateConfig(c *gin.Context) { var request UpdateConfigRequest diff --git a/backend/internal/securityaudit/prompt_handler_test.go b/backend/internal/securityaudit/prompt_handler_test.go index 51a4305f34..8561ad93e9 100644 --- a/backend/internal/securityaudit/prompt_handler_test.go +++ b/backend/internal/securityaudit/prompt_handler_test.go @@ -29,7 +29,9 @@ type fakePromptAdminService struct { deleteFilter func(context.Context, DeleteByFilterRequest, int64) (*DeleteResult, error) } -func (s *fakePromptAdminService) GetConfig() PublicConfig { return s.config } +func (s *fakePromptAdminService) GetConfig() (PublicConfig, error) { + return s.config, nil +} func (s *fakePromptAdminService) SaveConfig(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) { if s.save == nil { return PublicConfig{}, errors.New("unexpected SaveConfig call") @@ -156,6 +158,21 @@ func TestPromptAdminConfigRequiresVersionMapsConflictAndNeverEchoesToken(t *test }) } +func TestPromptAdminGetConfigReturnsSecretFreeUnavailableError(t *testing.T) { + const canary = "persisted-config-secret-canary" + repository := &switchableSettingRepository{loadErr: errors.New("failed to load token " + canary)} + manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}) + require.Error(t, manager.Reload(context.Background())) + service := &PromptService{config: manager} + + response := promptAdminRequest(t, promptAdminRouter(service), http.MethodGet, "/admin/prompt-audit/config", nil) + require.Equal(t, http.StatusServiceUnavailable, response.Code) + require.Contains(t, response.Body.String(), ErrorCodeConfigUnavailable) + require.NotContains(t, response.Body.String(), canary) + require.NotContains(t, response.Body.String(), `"config_version"`) + require.NotContains(t, response.Body.String(), `"token"`) +} + func TestPromptAdminProbeSupportsTemporaryOrSavedTokenWithoutEcho(t *testing.T) { const canary = "probe-token-canary" for _, tc := range []struct { diff --git a/backend/internal/securityaudit/prompt_service.go b/backend/internal/securityaudit/prompt_service.go index 71d0fadbe5..2d9d6605e0 100644 --- a/backend/internal/securityaudit/prompt_service.go +++ b/backend/internal/securityaudit/prompt_service.go @@ -166,7 +166,7 @@ func (s *PromptService) Evaluate(ctx context.Context, req Request) (*PromptDecis return s.evaluator.Evaluate(ctx, cfg, snapshot) } -func (s *PromptService) GetConfig() PublicConfig { return s.config.Public() } +func (s *PromptService) GetConfig() (PublicConfig, error) { return s.config.Public() } func (s *PromptService) SaveConfig(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) { return s.config.Save(ctx, req, actorID) diff --git a/backend/internal/securityaudit/prompt_types.go b/backend/internal/securityaudit/prompt_types.go index 34611444e0..7b25c33bdd 100644 --- a/backend/internal/securityaudit/prompt_types.go +++ b/backend/internal/securityaudit/prompt_types.go @@ -12,11 +12,12 @@ const ( ConfigInvalidationChannel = "sub2api:prompt_guard:config:invalidate" PayloadKeyPrefix = "sub2api:prompt_audit:payload:" - ErrorCodeBlocked = "prompt_guard_blocked" - ErrorCodeUnavailable = "prompt_guard_unavailable" - ErrorCodeInvalidResponse = "prompt_guard_invalid_response" - ErrorCodeConfigConflict = "prompt_audit_config_conflict" - ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled" + ErrorCodeBlocked = "prompt_guard_blocked" + ErrorCodeUnavailable = "prompt_guard_unavailable" + ErrorCodeInvalidResponse = "prompt_guard_invalid_response" + ErrorCodeConfigConflict = "prompt_audit_config_conflict" + ErrorCodeConfigUnavailable = "prompt_audit_config_unavailable" + ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled" DefaultGuardModel = "sileader/qwen3guard:0.6b" ) diff --git a/backend/internal/securityaudit/prompt_worker_test.go b/backend/internal/securityaudit/prompt_worker_test.go index 5c39be8e44..a91569c2cf 100644 --- a/backend/internal/securityaudit/prompt_worker_test.go +++ b/backend/internal/securityaudit/prompt_worker_test.go @@ -48,7 +48,7 @@ func (s *fakeConfigStore) EffectiveMode() Mode { return s.cfg.EffectiveMode() } func (s *fakeConfigStore) BlockingActivationDegraded() bool { return false } -func (s *fakeConfigStore) Public() PublicConfig { return PublicConfig{} } +func (s *fakeConfigStore) Public() (PublicConfig, error) { return PublicConfig{}, nil } func (s *fakeConfigStore) Save(context.Context, UpdateConfigRequest, int64) (PublicConfig, error) { return PublicConfig{}, nil }