diff --git a/core/application/config_file_watcher.go b/core/application/config_file_watcher.go index e99de63e6..a5f7d5f48 100644 --- a/core/application/config_file_watcher.go +++ b/core/application/config_file_watcher.go @@ -215,6 +215,7 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand envBackendGalleries := slices.Equal(appConfig.BackendGalleries, startupAppConfig.BackendGalleries) envAutoloadGalleries := appConfig.AutoloadGalleries == startupAppConfig.AutoloadGalleries envAutoloadBackendGalleries := appConfig.AutoloadBackendGalleries == startupAppConfig.AutoloadBackendGalleries + envPIIDefaultDetectors := slices.Equal(appConfig.PIIDefaultDetectors, startupAppConfig.PIIDefaultDetectors) envAgentJobRetentionDays := appConfig.AgentJobRetentionDays == startupAppConfig.AgentJobRetentionDays envForceEvictionWhenBusy := appConfig.ForceEvictionWhenBusy == startupAppConfig.ForceEvictionWhenBusy envLRUEvictionMaxRetries := appConfig.LRUEvictionMaxRetries == startupAppConfig.LRUEvictionMaxRetries @@ -335,6 +336,15 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand if settings.AutoloadBackendGalleries != nil && !envAutoloadBackendGalleries { appConfig.AutoloadBackendGalleries = *settings.AutoloadBackendGalleries } + if settings.PIIDefaultDetectors != nil && !envPIIDefaultDetectors { + // Request-side default redaction reads this live via + // ResolvePIIPolicy, so a file edit takes effect on the next chat + // request. The MITM listener resolves its per-host detector map + // once at start, so a raw file edit reaches cloud-proxy traffic + // only after a restart or a POST /api/settings (which rebuilds + // the listener) — the admin UI uses the latter. + appConfig.PIIDefaultDetectors = append([]string(nil), (*settings.PIIDefaultDetectors)...) + } if settings.AutoUpgradeBackends != nil { appConfig.AutoUpgradeBackends = *settings.AutoUpgradeBackends } diff --git a/core/application/runtime_settings_branding_test.go b/core/application/runtime_settings_branding_test.go index 6300f4456..763ede4b1 100644 --- a/core/application/runtime_settings_branding_test.go +++ b/core/application/runtime_settings_branding_test.go @@ -109,6 +109,52 @@ var _ = Describe("loadRuntimeSettingsFromFile", func() { }) }) + // Instance-wide default PII detectors. The file is the only source (no + // env var), and the loader runs immediately before startMITMIfConfigured, + // so a regression here means the cloud-proxy MITM listener resolves an + // empty detector set at boot and forwards intercepted traffic unredacted — + // even though pii_default_detectors is on disk and the MITM model has PII + // enabled. It also breaks request-side default redaction the same way. + Describe("PII default detectors", func() { + It("loads pii_default_detectors from the file", func() { + cfg := &config.ApplicationConfig{DynamicConfigsDir: seedSettings(`{"pii_default_detectors": ["privacy-filter-nemotron", "secret-filter"]}`)} + loadRuntimeSettingsFromFile(cfg) + Expect(cfg.PIIDefaultDetectors).To(Equal([]string{"privacy-filter-nemotron", "secret-filter"})) + }) + + It("does not override an env/CLI-set value (LOCALAI_PII_DEFAULT_DETECTORS)", func() { + cfg := &config.ApplicationConfig{ + DynamicConfigsDir: seedSettings(`{"pii_default_detectors": ["from-file"]}`), + PIIDefaultDetectors: []string{"from-env"}, // simulate WithPIIDefaultDetectors(env) + } + loadRuntimeSettingsFromFile(cfg) + Expect(cfg.PIIDefaultDetectors).To(Equal([]string{"from-env"}), "env var must win over the persisted file value") + }) + }) + + // The live file watcher applies pii_default_detectors on a runtime change + // the same way it handles galleries/threads/etc.: env-set values (current + // == startup snapshot) are left alone, otherwise the file value is applied + // to the live config so request-side default redaction picks it up without + // a restart. + Describe("file watcher: pii_default_detectors", func() { + It("applies a changed file value to the live config", func() { + startup := config.ApplicationConfig{} // no env baseline + live := &config.ApplicationConfig{PIIDefaultDetectors: []string{"old"}} + handler := readRuntimeSettingsJson(startup) + Expect(handler([]byte(`{"pii_default_detectors":["new-a","new-b"]}`), live)).To(Succeed()) + Expect(live.PIIDefaultDetectors).To(Equal([]string{"new-a", "new-b"})) + }) + + It("leaves an env-controlled value untouched", func() { + startup := config.ApplicationConfig{PIIDefaultDetectors: []string{"from-env"}} + live := &config.ApplicationConfig{PIIDefaultDetectors: []string{"from-env"}} + handler := readRuntimeSettingsJson(startup) + Expect(handler([]byte(`{"pii_default_detectors":["from-file"]}`), live)).To(Succeed()) + Expect(live.PIIDefaultDetectors).To(Equal([]string{"from-env"}), "env-controlled detectors must not be overwritten by the file") + }) + }) + // The Agent Pool block has a mix of zero and non-zero defaults // (Enabled=true, EmbeddingModel="granite-...", MaxChunkingSize=400, // VectorEngine="chromem", AgentHubURL="https://agenthub.localai.io"). diff --git a/core/application/startup.go b/core/application/startup.go index 352d66dab..1e5a7a73b 100644 --- a/core/application/startup.go +++ b/core/application/startup.go @@ -750,6 +750,20 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) { options.MITMListen = *settings.MITMListen } + // Instance-wide default PII detectors. LOCALAI_PII_DEFAULT_DETECTORS (via + // WithPIIDefaultDetectors) wins when set; otherwise the file is the source + // — apply it only when the env/CLI left the value empty, mirroring the + // "env > file" precedence used for the other fields. This must land before + // startMITMIfConfigured (called right after this loader): the cloud-proxy + // listener resolves each intercept host's detectors once at start via + // ResolvePIIPolicy, and a MITM model that names no detectors of its own + // falls back to these defaults. Without it the listener (and request-side + // default redaction) starts with an empty detector set and forwards + // traffic unredacted even though pii_default_detectors is on disk. + if settings.PIIDefaultDetectors != nil && len(options.PIIDefaultDetectors) == 0 { + options.PIIDefaultDetectors = append([]string(nil), (*settings.PIIDefaultDetectors)...) + } + // Backend upgrade flags if settings.AutoUpgradeBackends != nil { if !options.AutoUpgradeBackends { diff --git a/core/cli/run.go b/core/cli/run.go index 23eebaaa0..abb0cdbf1 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -181,6 +181,8 @@ type RunCMD struct { // Cloud-proxy MITM listener (off by default). MITMListen string `env:"LOCALAI_MITM_LISTEN" help:"Address (host:port) for the cloudproxy MITM listener. Empty = disabled. Clients set HTTPS_PROXY=http://:. Intercept hosts are declared per-model via the model YAML mitm.hosts: block; create one from the Add Model UI." group:"middleware"` MITMCADir string `env:"LOCALAI_MITM_CA_DIR" type:"path" help:"Directory holding the MITM proxy CA cert + key. Defaults to /mitm-ca." group:"middleware"` + + PIIDefaultDetectors []string `env:"LOCALAI_PII_DEFAULT_DETECTORS" help:"Instance-wide default PII/secret detector model names applied to any PII-enabled model (chiefly cloud-proxy / MITM models) that names no pii.detectors of its own. Comma-separated, e.g. privacy-filter-nemotron,secret-filter. Takes precedence over the value persisted via the Middleware UI." group:"middleware"` } func (r *RunCMD) Run(ctx *cliContext.Context) error { @@ -243,6 +245,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error { config.WithAPIAddress(r.Address), config.WithMITMListen(r.MITMListen), config.WithMITMCADir(r.MITMCADir), + config.WithPIIDefaultDetectors(r.PIIDefaultDetectors), config.WithAgentJobRetentionDays(r.AgentJobRetentionDays), config.WithLlamaCPPTunnelCallback(func(tunnels []string) { tunnelEnvVar := strings.Join(tunnels, ",") diff --git a/core/config/application_config.go b/core/config/application_config.go index 54eb5cb99..87acd6bd5 100644 --- a/core/config/application_config.go +++ b/core/config/application_config.go @@ -712,6 +712,18 @@ func WithMITMCADir(dir string) AppOption { } } +// WithPIIDefaultDetectors sets the instance-wide default PII/secret detector +// model names applied to any PII-enabled model (chiefly cloud-proxy / MITM +// models) that names no pii.detectors of its own. CLI/env: +// LOCALAI_PII_DEFAULT_DETECTORS. Empty leaves the value to +// runtime_settings.json / the Middleware UI; a non-empty value takes +// precedence over the file (env > file). +func WithPIIDefaultDetectors(detectors []string) AppOption { + return func(o *ApplicationConfig) { + o.PIIDefaultDetectors = detectors + } +} + func WithDynamicConfigDir(dynamicConfigsDir string) AppOption { return func(o *ApplicationConfig) { o.DynamicConfigsDir = dynamicConfigsDir diff --git a/core/http/endpoints/localai/settings.go b/core/http/endpoints/localai/settings.go index be6358939..8033f07d5 100644 --- a/core/http/endpoints/localai/settings.go +++ b/core/http/endpoints/localai/settings.go @@ -271,7 +271,14 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc { } } - if settings.MITMListen != nil { + // Rebuild the MITM listener when its address OR the instance-wide + // default detectors change. The per-host detector map is resolved once + // at listener start (startMITMLocked → ResolvePIIPolicy), so a + // default-detector change is otherwise invisible to cloud-proxy traffic + // until the next restart — an admin toggling a default detector would + // see no redaction. RestartMITM is a no-op when the listener is + // disabled (empty address). + if settings.MITMListen != nil || settings.PIIDefaultDetectors != nil { if err := app.RestartMITM(); err != nil { xlog.Error("Failed to restart MITM proxy", "error", err) return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{ diff --git a/core/http/endpoints/localai/settings_test.go b/core/http/endpoints/localai/settings_test.go index 7ba82e1a3..3974c5045 100644 --- a/core/http/endpoints/localai/settings_test.go +++ b/core/http/endpoints/localai/settings_test.go @@ -146,6 +146,24 @@ var _ = Describe("Settings endpoints", func() { Expect(*ondisk.PIIDefaultDetectors).To(Equal([]string{"det-a"})) }) + // The MITM listener resolves its per-host PII detectors once at start + // (startMITMLocked → ResolvePIIPolicy), and the handler used to restart it + // only when mitm_listen changed. So an admin toggling a default detector + // (the Middleware detector table POSTs only pii_default_detectors) left + // cloud-proxy traffic unredacted until the next reboot. A + // pii_default_detectors change must now rebuild the listener. + It("rebuilds the MITM listener when only pii_default_detectors changes", func() { + rec := post(`{"mitm_listen":"127.0.0.1:0"}`) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + srv1 := app.MITMServer() + Expect(srv1).ToNot(BeNil(), "listener should be running after mitm_listen is set") + + rec = post(`{"pii_default_detectors":["det-a"]}`) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(app.MITMServer()).ToNot(BeIdenticalTo(srv1), + "a default-detector change must restart the listener so it picks up the new detectors") + }) + // Residual #9125: enabling the watchdog from a cold (off) state via the // React master toggle must start the live watchdog immediately, without a // restart. The toggle posts watchdog_idle_enabled/busy_enabled=true while diff --git a/docs/content/features/middleware.md b/docs/content/features/middleware.md index 5f03cb925..397af3c92 100644 --- a/docs/content/features/middleware.md +++ b/docs/content/features/middleware.md @@ -185,6 +185,13 @@ It is persisted through `POST /api/settings` and read live, so a change takes effect on the next request without a restart. A default that names a model no longer loaded still appears (marked *not loaded*) so it can be toggled off. +The default set can also be supplied out-of-band with the +`LOCALAI_PII_DEFAULT_DETECTORS` environment variable (comma-separated model +names, e.g. `privacy-filter-nemotron,secret-filter`). When set it takes +precedence over the value persisted via the UI (env > file), which is the +right behaviour for immutable container deployments that pin filtering policy +at boot rather than via the admin UI. + This is what makes `cloud-proxy` / MITM redaction work out of the box: those backends default to PII-enabled but ship no detector list, so without a default detector the filter runs with nothing to scan. Set one here and