diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 02d6f00b6..24acd0e8c 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -155,10 +155,11 @@ func runMainServer() { defer app.Cleanup() if app.PromptAudit != nil { if err := app.PromptAudit.Start(context.Background()); err != nil { - // Startup continues so unrelated APIs stay up, but Prompt Audit itself - // fails closed (unavailable) until a later reload installs a trusted - // snapshot—avoiding a silent ModeOff bypass of persisted blocking policy. - log.Printf("Prompt Audit started in degraded fail-closed state: %v", err) + // Startup continues so unrelated APIs stay up. Fail-closed (unavailable) + // applies only when a persisted blocking policy was observed; without + // blocking intent, Prompt Audit stays ModeOff so the gateway remains + // usable and administrators can still disable the feature (#4560). + log.Printf("Prompt Audit started in degraded state: %v", err) } } diff --git a/backend/internal/securityaudit/prompt_config.go b/backend/internal/securityaudit/prompt_config.go index 2f6485ad8..3ad690d4d 100644 --- a/backend/internal/securityaudit/prompt_config.go +++ b/backend/internal/securityaudit/prompt_config.go @@ -41,6 +41,8 @@ type ConfigStore interface { EffectiveMode() Mode // BlockingActivationDegraded is true when storage intent requires blocking // but no usable blocking snapshot is active (cold start or failed reload). + // 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 Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) diff --git a/backend/internal/securityaudit/prompt_config_store.go b/backend/internal/securityaudit/prompt_config_store.go index 795a8476c..7bc70f4c0 100644 --- a/backend/internal/securityaudit/prompt_config_store.go +++ b/backend/internal/securityaudit/prompt_config_store.go @@ -37,9 +37,11 @@ type ConfigManager struct { // activated. A config version alone cannot distinguish async from blocking. expectedBlocking atomic.Bool // configUntrusted is set when a load/reload fails before a trustworthy - // snapshot is installed. While set, EffectiveMode fails closed so a - // persisted blocking policy cannot be silently skipped after startup or - // invalidation errors. + // snapshot is installed. Combined with expectedBlocking, EffectiveMode + // fails closed so a persisted blocking policy cannot be silently skipped + // after startup or invalidation errors. Without blocking intent, untrusted + // alone must not force ModeBlocking—Prompt Audit is default-off and must + // not take the gateway down for every API request (see issue #4560). configUntrusted atomic.Bool stateMu sync.RWMutex @@ -147,12 +149,15 @@ func (m *ConfigManager) BlockingActivationDegraded() bool { if m == nil { return false } - if m.configUntrusted.Load() { - return true - } + // Fail closed only when storage intent requires blocking. Untrusted config + // without blocking intent must remain ModeOff so administrators can still + // operate the gateway and turn Prompt Audit off after a failed reload. if !m.expectedBlocking.Load() { return false } + if m.configUntrusted.Load() { + return true + } active, ok := m.Active() if !ok { return true @@ -264,6 +269,9 @@ func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actor m.expected.Store(next.ConfigVersion) m.expectedBlocking.Store(active.RiskControlEnabled && next.Enabled && next.BlockingEnabled) m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(next), active: cloneActiveConfig(active), loadedAt: m.clock.Now()}) + // A successful admin save installs a trustworthy snapshot; clear any prior + // fail-closed degradation so disabling audit actually takes effect. + m.configUntrusted.Store(false) m.clearLoadError() LogInfo(EventConfigUpdated, map[string]any{ "config_version": next.ConfigVersion, "status": "updated", diff --git a/backend/internal/securityaudit/prompt_config_test.go b/backend/internal/securityaudit/prompt_config_test.go index ad25dac9e..22ea50a35 100644 --- a/backend/internal/securityaudit/prompt_config_test.go +++ b/backend/internal/securityaudit/prompt_config_test.go @@ -137,14 +137,85 @@ func (errorSettingRepository) GetMultiple(context.Context, []string) (map[string return nil, errors.New("settings unavailable") } -func TestConfigManagerStartupLoadFailureFailsClosedWithoutSnapshot(t *testing.T) { +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. manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{}) err := manager.Start(context.Background()) require.Error(t, err) require.True(t, manager.configUntrusted.Load()) + require.False(t, manager.BlockingActivationDegraded()) + require.Equal(t, ModeOff, manager.EffectiveMode()) + + service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)} + decision, evalErr := service.Evaluate(context.Background(), Request{ + Protocol: "openai_chat_completions", + Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`), + }) + require.NoError(t, evalErr) + require.NotNil(t, decision) + require.Equal(t, DecisionAllow, decision.Kind) + require.NoError(t, manager.Shutdown(context.Background())) +} + +func TestConfigManagerStartupLoadFailureFailsClosedWhenBlockingIntended(t *testing.T) { + manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{}) + // Simulate intent observed before a later load failure (e.g. decrypt error). + manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":3}`, true) + manager.markConfigUntrusted() require.True(t, manager.BlockingActivationDegraded()) require.Equal(t, ModeBlocking, manager.EffectiveMode()) - require.NoError(t, manager.Shutdown(context.Background())) + + service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)} + decision, err := service.Evaluate(context.Background(), Request{ + Protocol: "openai_chat_completions", + Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`), + }) + require.Error(t, err) + require.Nil(t, decision) + var guardErr *GuardError + require.ErrorAs(t, err, &guardErr) + require.Equal(t, ErrorCodeUnavailable, guardErr.Code) +} + +func TestConfigManagerUntrustedClearsOnSuccessfulDisable(t *testing.T) { + // After a degraded fail-closed period, saving disabled config must restore ModeOff. + manager := &ConfigManager{encryptor: prefixEncryptor{}, clock: fixedClock{}} + manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":5}`, true) + manager.markConfigUntrusted() + require.Equal(t, ModeBlocking, manager.EffectiveMode()) + + // Install a trusted disabled snapshot the same way Save does after commit. + disabled := DefaultStorageConfig() + disabled.ConfigVersion = 6 + disabled.Enabled = false + disabled.BlockingEnabled = false + active, err := ActiveFromStorage(disabled, true, manager.encryptor) + require.NoError(t, err) + manager.expected.Store(disabled.ConfigVersion) + manager.expectedBlocking.Store(false) + manager.snapshot.Store(&activeConfigSnapshot{storage: disabled, active: active, loadedAt: manager.clock.Now()}) + manager.configUntrusted.Store(false) + + require.False(t, manager.BlockingActivationDegraded()) + require.Equal(t, ModeOff, manager.EffectiveMode()) + + service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)} + decision, evalErr := service.Evaluate(context.Background(), Request{ + Protocol: "openai_chat_completions", + Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`), + }) + require.NoError(t, evalErr) + require.Equal(t, DecisionAllow, decision.Kind) +} + +func TestConfigManagerUntrustedWithoutBlockingDoesNotForceBlockingMode(t *testing.T) { + manager := &ConfigManager{} + manager.observeExpectedState(`{"enabled":true,"blocking_enabled":false,"config_version":2}`, true) + manager.markConfigUntrusted() + require.False(t, manager.expectedBlocking.Load()) + require.False(t, manager.BlockingActivationDegraded()) + require.Equal(t, ModeOff, manager.EffectiveMode(), "async intent + untrusted must not force blocking unavailable") } func TestParseLegacyConfigDefaultsMissingFieldsWithoutEnablingBlocking(t *testing.T) {