Skip to content

Commit 4e82f2b

Browse files
committed
refactor(pii): NER-centric PII filter; remove the regex tier
Invert the PII filtering model so detection policy lives on the NER (token-classification) detector model itself, and consuming models just reference detectors by name. Schema (core/config/model_config.go): - New top-level `pii_detection:` block on a detector model (min_score, default_action, entity_actions) + accessors. - Consumer `pii:` is now `{ enabled, detectors: [<model>...] }`. - `pii.patterns` / `pii.ner` kept only as untyped deprecated shadows so old YAMLs still parse; Validate() warns (does not fail). Middleware (core/services/routing/pii): - Redactor is now a stateless handle; RedactNER(ctx, text, []NERConfig) runs every detector, unions hits, and overlap-merges (block>mask>allow). - NERDetectorResolver returns (NERConfig, bool); the resolver reads each detector model's pii_detection policy (NERConfigFromRaw). - RequestMiddleware is NER-only, multi-detector, fail-closed on a detector that can't be resolved or errors. Regex tier fully removed: patterns.go, config.go (LoadConfig/--pii-config), the response-side StreamFilter, the /api/pii/{patterns,test,decide,persist} admin routes, the MCP list/test/set/persist pattern tools, and the dead --pii-config/--disable-pii AppOptions + runtime_settings overrides. Output/ streaming redaction is dropped for now (NER is request-side only). Cloud-proxy/MITM now runs NER on the request input (mitm/handler.go gets a per-host []NERConfig resolved at listener start), fail-closed; the response is forwarded unmodified. Capability metadata: pii.detectors (model-multi-select filtered to token_classify) + pii_detection.* registry entries; config-metadata autocomplete gains a token_classify case; API instructions rewritten. UI (React) is a follow-up; this is the Go side. Assisted-by: claude-code:claude-opus-4-8 [Claude Code]
1 parent 238ac29 commit 4e82f2b

55 files changed

Lines changed: 909 additions & 3794 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

core/application/application.go

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ import (
1212
"github.com/mudler/LocalAI/core/http/auth"
1313
mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp"
1414
"github.com/mudler/LocalAI/core/services/agentpool"
15+
"github.com/mudler/LocalAI/core/services/cloudproxy/mitm"
1516
"github.com/mudler/LocalAI/core/services/facerecognition"
1617
"github.com/mudler/LocalAI/core/services/galleryop"
1718
"github.com/mudler/LocalAI/core/services/monitoring"
1819
"github.com/mudler/LocalAI/core/services/nodes"
1920
"github.com/mudler/LocalAI/core/services/routing/admission"
2021
"github.com/mudler/LocalAI/core/services/routing/billing"
21-
"github.com/mudler/LocalAI/core/services/cloudproxy/mitm"
2222
"github.com/mudler/LocalAI/core/services/routing/pii"
2323
"github.com/mudler/LocalAI/core/services/routing/piidetector"
2424
"github.com/mudler/LocalAI/core/services/routing/router"
@@ -72,15 +72,15 @@ type Application struct {
7272
// 1-to-1 host↔model invariant the dispatcher relies on. Read by
7373
// /api/middleware/status so the admin UI can surface the cause.
7474
mitmHostConflicts atomic.Pointer[map[string][]string]
75-
routerDecisions router.DecisionStore
76-
routerRegistry *router.Registry
77-
admissionLimiter *admission.Limiter
78-
watchdogMutex sync.Mutex
79-
watchdogStop chan bool
80-
p2pMutex sync.Mutex
81-
p2pCtx context.Context
82-
p2pCancel context.CancelFunc
83-
agentJobMutex sync.Mutex
75+
routerDecisions router.DecisionStore
76+
routerRegistry *router.Registry
77+
admissionLimiter *admission.Limiter
78+
watchdogMutex sync.Mutex
79+
watchdogStop chan bool
80+
p2pMutex sync.Mutex
81+
p2pCtx context.Context
82+
p2pCancel context.CancelFunc
83+
agentJobMutex sync.Mutex
8484

8585
// Distributed mode services (nil when not in distributed mode)
8686
distributed *DistributedServices
@@ -255,22 +255,28 @@ func (a *Application) PIIEvents() pii.EventStore {
255255
return a.piiEvents
256256
}
257257

258-
// PIINERResolver returns the resolver the chat PII middleware's encoder
259-
// tier uses to turn a configured NER model name into a detector. It
260-
// looks the model up in the config loader and binds a token-classifier
261-
// over the shared model loader (lazy — the model loads on first
262-
// Detect). Unknown or unconfigured names resolve to nil so the redactor
263-
// falls back to regex-only. Pass it via pii.WithNERResolver.
258+
// PIINERResolver returns the resolver the chat PII middleware uses to
259+
// turn a configured detector model name into a ready-to-use NERConfig:
260+
// a token-classifier bound over the shared model loader (lazy — the
261+
// model loads on first Detect) plus the detection policy read from that
262+
// model's own pii_detection block. Unknown names resolve to (zero,
263+
// false) so the middleware fails closed. Pass it via pii.WithNERResolver.
264264
func (a *Application) PIINERResolver() pii.NERDetectorResolver {
265-
return func(modelName string) pii.NERDetector {
265+
return func(modelName string) (pii.NERConfig, bool) {
266266
if modelName == "" {
267-
return nil
267+
return pii.NERConfig{}, false
268268
}
269269
cfg, ok := a.ModelConfigLoader().GetModelConfig(modelName)
270270
if !ok {
271-
return nil
271+
return pii.NERConfig{}, false
272272
}
273-
return piidetector.New(a.ModelLoader(), cfg, a.ApplicationConfig())
273+
det := piidetector.New(a.ModelLoader(), cfg, a.ApplicationConfig())
274+
return pii.NERConfigFromRaw(
275+
det,
276+
cfg.PIIDetectionMinScore(),
277+
cfg.PIIDetectionDefaultAction(),
278+
cfg.PIIDetectionEntityActions(),
279+
), true
274280
}
275281
}
276282

core/application/mitm.go

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/mudler/LocalAI/core/config"
1010
"github.com/mudler/LocalAI/core/services/cloudproxy/mitm"
11+
"github.com/mudler/LocalAI/core/services/routing/pii"
1112
"github.com/mudler/xlog"
1213
)
1314

@@ -91,25 +92,39 @@ func startMITMLocked(app *Application, options *config.ApplicationConfig) error
9192
}
9293
sort.Strings(effectiveHosts)
9394

94-
// Per-host PII gate inherits from the owning model's pii.enabled.
95-
// A non-cloud-proxy backend with no explicit pii.enabled resolves
96-
// to false → host is intercepted but the regex pass is skipped
97-
// (audit events still record).
98-
var piiDisabled []string
95+
// Per-host NER detectors come from the owning model's pii.detectors
96+
// (resolved against each detector model's pii_detection policy). A
97+
// host whose model has pii.enabled=false, lists no detectors, or
98+
// whose detectors can't be resolved gets no entry → it is intercepted
99+
// and forwarded unredacted (audit events still record traffic). An
100+
// unresolvable detector is recorded as an error-detector so the
101+
// request fails closed at request time rather than leaking.
102+
resolver := app.PIINERResolver()
103+
detectorsByHost := map[string][]pii.NERConfig{}
99104
for host, modelName := range ownership.Owners {
100105
cfg, exists := app.backendLoader.GetModelConfig(modelName)
101-
if !exists {
106+
if !exists || !cfg.PIIIsEnabled() {
102107
continue
103108
}
104-
if !cfg.PIIIsEnabled() {
105-
piiDisabled = append(piiDisabled, host)
109+
detectors := cfg.PIIDetectors()
110+
if len(detectors) == 0 {
111+
continue
112+
}
113+
cfgs := make([]pii.NERConfig, 0, len(detectors))
114+
for _, name := range detectors {
115+
nc, ok := resolver(name)
116+
if !ok {
117+
xlog.Error("mitm: detector model not resolvable; requests to host will fail closed", "host", host, "detector", name)
118+
nc = pii.NERConfig{Detector: pii.NewErrNERDetector("detector model '" + name + "' not resolvable")}
119+
}
120+
cfgs = append(cfgs, nc)
106121
}
122+
detectorsByHost[host] = cfgs
107123
}
108124

109125
handler := mitm.NewPIIHandler(mitm.PIIHandlerOptions{
110-
Redactor: app.piiRedactor,
111-
EventStore: app.piiEvents,
112-
HostsWithPIIDisabled: piiDisabled,
126+
EventStore: app.piiEvents,
127+
DetectorsByHost: detectorsByHost,
113128
})
114129

115130
srv, err := mitm.NewServer(mitm.Config{
@@ -132,7 +147,7 @@ func startMITMLocked(app *Application, options *config.ApplicationConfig) error
132147
"ca_dir", caDir,
133148
"intercept_hosts", effectiveHosts,
134149
"model_owned_hosts", len(ownership.Owners),
135-
"pii_disabled_hosts", len(piiDisabled),
150+
"pii_detector_hosts", len(detectorsByHost),
136151
)
137152
return nil
138153
}

core/application/startup.go

Lines changed: 15 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ import (
2323
"github.com/mudler/LocalAI/core/services/routing/pii"
2424
"github.com/mudler/LocalAI/core/services/routing/router"
2525
"github.com/mudler/LocalAI/core/services/storage"
26-
"github.com/mudler/LocalAI/pkg/signals"
2726
coreStartup "github.com/mudler/LocalAI/core/startup"
2827
"github.com/mudler/LocalAI/internal"
28+
"github.com/mudler/LocalAI/pkg/signals"
2929
"github.com/mudler/LocalAI/pkg/vram"
3030

3131
"github.com/mudler/LocalAI/pkg/model"
@@ -53,7 +53,6 @@ func New(opts ...config.AppOption) (*Application, error) {
5353
caps, err := xsysinfo.CPUCapabilities()
5454
if err == nil {
5555
xlog.Debug("CPU capabilities", "capabilities", caps)
56-
5756
}
5857
gpus, err := xsysinfo.GPUs()
5958
if err == nil {
@@ -68,26 +67,26 @@ func New(opts ...config.AppOption) (*Application, error) {
6867
return nil, fmt.Errorf("models path cannot be empty")
6968
}
7069

71-
err = os.MkdirAll(options.SystemState.Model.ModelsPath, 0750)
70+
err = os.MkdirAll(options.SystemState.Model.ModelsPath, 0o750)
7271
if err != nil {
7372
return nil, fmt.Errorf("unable to create ModelPath: %q", err)
7473
}
7574
if options.GeneratedContentDir != "" {
76-
err := os.MkdirAll(options.GeneratedContentDir, 0750)
75+
err := os.MkdirAll(options.GeneratedContentDir, 0o750)
7776
if err != nil {
7877
return nil, fmt.Errorf("unable to create ImageDir: %q", err)
7978
}
8079
}
8180
if options.UploadDir != "" {
82-
err := os.MkdirAll(options.UploadDir, 0750)
81+
err := os.MkdirAll(options.UploadDir, 0o750)
8382
if err != nil {
8483
return nil, fmt.Errorf("unable to create UploadDir: %q", err)
8584
}
8685
}
8786

8887
// Create and migrate data directory
8988
if options.DataPath != "" {
90-
if err := os.MkdirAll(options.DataPath, 0750); err != nil {
89+
if err := os.MkdirAll(options.DataPath, 0o750); err != nil {
9190
return nil, fmt.Errorf("unable to create DataPath: %q", err)
9291
}
9392
// Migrate data from DynamicConfigsDir to DataPath if needed
@@ -192,44 +191,14 @@ func New(opts ...config.AppOption) (*Application, error) {
192191
xlog.Info("stats: disabled by --disable-stats")
193192
}
194193

195-
// Wire the regex PII filter. Default-on: a single-user box gets
196-
// the built-in pattern set the first time it starts, with email/
197-
// phone/SSN/credit-card on mask and api_key_prefix on block. If
198-
// the operator wants different actions, --pii-config points at a
199-
// YAML file that overrides per-id; --disable-pii turns it off
200-
// entirely.
201-
if !options.DisablePII {
202-
patterns, err := pii.LoadConfig(options.PIIConfigPath)
203-
if err != nil {
204-
return nil, fmt.Errorf("pii config: %w", err)
205-
}
206-
application.piiRedactor = pii.NewRedactor(patterns)
207-
application.piiEvents = pii.NewMemoryEventStore(0)
208-
// Apply persisted per-pattern overrides — admins toggling
209-
// action/disabled via the UI and clicking "Save to disk" land
210-
// here on the next start. Bad ids are warned and ignored so a
211-
// stale entry doesn't block startup.
212-
for id, ov := range options.PIIPatternOverrides {
213-
if ov.Action != nil {
214-
if err := application.piiRedactor.SetAction(id, pii.Action(*ov.Action)); err != nil {
215-
xlog.Warn("pii: persisted override skipped", "pattern", id, "error", err)
216-
continue
217-
}
218-
}
219-
if ov.Disabled != nil {
220-
if err := application.piiRedactor.SetDisabled(id, *ov.Disabled); err != nil {
221-
xlog.Warn("pii: persisted disable skipped", "pattern", id, "error", err)
222-
}
223-
}
224-
}
225-
xlog.Info("pii: filter enabled",
226-
"patterns", len(patterns),
227-
"config_path", options.PIIConfigPath,
228-
"persisted_overrides", len(options.PIIPatternOverrides),
229-
)
230-
} else {
231-
xlog.Info("pii: disabled by --disable-pii")
232-
}
194+
// Wire the PII filter subsystem. The redactor is now a stateless
195+
// handle — detection is driven by per-model NER detectors
196+
// (pii.detectors → the detector model's pii_detection policy), run
197+
// request-side by the chat middleware and the MITM input path. The
198+
// regex tier was removed; redaction is opt-in per model via
199+
// PIIIsEnabled(). The event store backs the /api/pii/events audit log.
200+
application.piiRedactor = &pii.Redactor{}
201+
application.piiEvents = pii.NewMemoryEventStore(0)
233202

234203
// Wire the routing decision log. Always-on when stats are enabled —
235204
// the per-router admin page reads this as the live activity feed
@@ -496,7 +465,7 @@ func startWatcher(options *config.ApplicationConfig) {
496465
if _, err := os.Stat(options.DynamicConfigsDir); err != nil {
497466
if os.IsNotExist(err) {
498467
// We try to create the directory if it does not exist and was specified
499-
if err := os.MkdirAll(options.DynamicConfigsDir, 0700); err != nil {
468+
if err := os.MkdirAll(options.DynamicConfigsDir, 0o700); err != nil {
500469
xlog.Error("failed creating DynamicConfigsDir", "error", err)
501470
}
502471
} else {
@@ -743,16 +712,6 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
743712
options.MITMListen = *settings.MITMListen
744713
}
745714

746-
// PII pattern overrides — file is the only source; CLI flags don't
747-
// reach into this map. Apply unconditionally when present; the
748-
// redactor wiring below sees the result on first construction.
749-
if settings.PIIPatternOverrides != nil {
750-
options.PIIPatternOverrides = make(map[string]config.PIIPatternRuntimeOverride, len(*settings.PIIPatternOverrides))
751-
for id, ov := range *settings.PIIPatternOverrides {
752-
options.PIIPatternOverrides[id] = ov
753-
}
754-
}
755-
756715
// Backend upgrade flags
757716
if settings.AutoUpgradeBackends != nil {
758717
if !options.AutoUpgradeBackends {
@@ -903,7 +862,7 @@ func loadOrGenerateHMACSecret(path string) (string, error) {
903862
}
904863
secret := hex.EncodeToString(b)
905864

906-
if err := os.WriteFile(path, []byte(secret), 0600); err != nil {
865+
if err := os.WriteFile(path, []byte(secret), 0o600); err != nil {
907866
return "", fmt.Errorf("failed to persist HMAC secret: %w", err)
908867
}
909868

core/config/application_config.go

Lines changed: 6 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -48,25 +48,6 @@ type ApplicationConfig struct {
4848
// touch disk or memory.
4949
DisableStats bool
5050

51-
// PIIConfigPath points to an optional YAML file describing the PII
52-
// pattern set. When empty, the routing/pii module's DefaultPatterns()
53-
// (email, phone, SSN, credit card, IPv4, API key prefixes) are
54-
// loaded with their default actions. Each entry overrides the
55-
// matching default by ID:
56-
//
57-
// patterns:
58-
// - id: email
59-
// action: allow # downgrade default mask -> allow (log only)
60-
// - id: ssn
61-
// action: block # upgrade default mask -> block
62-
//
63-
// Unknown ids are rejected with a clear error at startup.
64-
PIIConfigPath string
65-
66-
// DisablePII turns the regex PII filter off entirely. Default
67-
// (false) enables it on the OpenAI chat completions route.
68-
DisablePII bool
69-
7051
// MITMListen is the address (host:port) the cloudproxy MITM
7152
// listener binds on. Empty disables the MITM proxy entirely.
7253
// Use case: redacting PII from Claude Code / Codex CLI traffic
@@ -81,13 +62,6 @@ type ApplicationConfig struct {
8162
// file is mode 0600.
8263
MITMCADir string
8364

84-
85-
// PIIPatternOverrides applies persisted per-id deltas (action,
86-
// disabled) to the live redactor at startup. Loaded from
87-
// runtime_settings.json and applied right after pii.NewRedactor.
88-
// nil/empty leaves the YAML defaults in place.
89-
PIIPatternOverrides map[string]PIIPatternRuntimeOverride
90-
9165
DisableWebUI bool
9266
OllamaAPIRootEndpoint bool
9367
EnforcePredownloadScans bool
@@ -116,11 +90,11 @@ type ApplicationConfig struct {
11690
// --require-backend-integrity / LOCALAI_REQUIRE_BACKEND_INTEGRITY.
11791
RequireBackendIntegrity bool
11892

119-
SingleBackend bool // Deprecated: use MaxActiveBackends = 1 instead
120-
MaxActiveBackends int // Maximum number of active backends (0 = unlimited, 1 = single backend mode)
121-
WatchDogIdle bool
122-
WatchDogBusy bool
123-
WatchDog bool
93+
SingleBackend bool // Deprecated: use MaxActiveBackends = 1 instead
94+
MaxActiveBackends int // Maximum number of active backends (0 = unlimited, 1 = single backend mode)
95+
WatchDogIdle bool
96+
WatchDogBusy bool
97+
WatchDog bool
12498

12599
// Memory Reclaimer settings (works with GPU if available, otherwise RAM)
126100
MemoryReclaimerEnabled bool // Enable memory threshold monitoring
@@ -583,6 +557,7 @@ func WithJSONStringPreload(configFile string) AppOption {
583557
o.PreloadJSONModels = configFile
584558
}
585559
}
560+
586561
func WithConfigFile(configFile string) AppOption {
587562
return func(o *ApplicationConfig) {
588563
o.ConfigFile = configFile
@@ -671,21 +646,6 @@ func WithDisableStats(disable bool) AppOption {
671646
}
672647
}
673648

674-
// WithPIIConfigPath points the routing PII filter at a YAML config
675-
// file. CLI: --pii-config.
676-
func WithPIIConfigPath(path string) AppOption {
677-
return func(o *ApplicationConfig) {
678-
o.PIIConfigPath = path
679-
}
680-
}
681-
682-
// WithDisablePII turns the regex PII filter off. CLI: --disable-pii.
683-
func WithDisablePII(disable bool) AppOption {
684-
return func(o *ApplicationConfig) {
685-
o.DisablePII = disable
686-
}
687-
}
688-
689649
// WithMITMListen sets the address the cloudproxy MITM listener
690650
// binds on. Empty = disabled. CLI: --mitm-listen.
691651
func WithMITMListen(addr string) AppOption {
@@ -702,7 +662,6 @@ func WithMITMCADir(dir string) AppOption {
702662
}
703663
}
704664

705-
706665
func WithDynamicConfigDir(dynamicConfigsDir string) AppOption {
707666
return func(o *ApplicationConfig) {
708667
o.DynamicConfigsDir = dynamicConfigsDir

0 commit comments

Comments
 (0)