feat(pii): NER tier engine — privacy-filter.cpp backend + NER-centric PII filter (#10360)

Squashed feat/pii-ner-tier-engine rebased onto master (was 45 commits; see
backup/pii-ner-tier-engine-prerebase). Net change:

- privacy-filter.cpp: standalone GGML engine for the openai-privacy-filter
  PII/NER token classifier, wired as a LocalAI gRPC backend (CPU/CUDA/Vulkan).
  TokenClassify moves off the patched llama.cpp path onto this backend.
- PII filter reworked to be NER-centric (encoder/NER detection tier scanning
  whole conversations as one document), with a recreated bounded restricted-
  regex secret-matching pattern detector tier alongside it (per-model
  pii_detection.builtins / .patterns + core/services/routing/piipattern).
- Detection labelled by source (ner vs pattern); backend trace / confidence /
  debug observability; analyze/redact exposed as a synchronous API.
- Instance-wide default detector policy + per-usecase default-on; request
  filtering extended to completions, embeddings, edits & Ollama.
- React UI: NER-centric PII editor, detector-models table, pattern/builtins
  editor, middleware default-policy UI.
- Gallery: privacy-filter-multilingual token-classify model + NER install
  filter; token_classify known_usecase; batch sized to context for NER models.
  privacy-filter backend registered in the backend gallery (cpu/vulkan/cuda-13
  meta + image entries with a capabilities map) matching its CI matrix jobs,
  and an /import-model auto-detect importer (PrivacyFilterImporter, narrow
  privacy-filter GGUF detection) replacing the prior pref-only registration.

Reconciled against master's independent evolution:

- Dropped master's PIIPatternOverrides feature (global-pattern runtime
  overrides + /api/pii/patterns API + runtime_settings.json persistence). The
  per-model NER + pattern-detector design supersedes it; it was built on the
  global redactor pattern set this branch replaced.
- Reverted the llama.cpp Score carry-patch (0006-server-task-type-score):
  removed the patch and restored master's grpc-server.cpp Score RPC (direct
  llama_decode, slot-loop bypass) and LLAMA_VERSION pin, plus master's
  model_config validation forbidding score + chat/completion/embeddings on
  llama-cpp. token_classify is unaffected (it runs on the privacy-filter
  backend, not llama-cpp).

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
Richard Palethorpe
2026-06-18 11:45:22 +01:00
committed by GitHub
parent c133ca39dc
commit 3fa7b2955c
134 changed files with 6671 additions and 4223 deletions

View File

@@ -16,7 +16,6 @@ import (
"golang.org/x/net/http2"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/cloudproxy/ssewire"
"github.com/mudler/LocalAI/core/services/routing/pii"
"github.com/mudler/LocalAI/core/services/routing/piiadapter"
"github.com/mudler/LocalAI/pkg/httpclient"
@@ -24,8 +23,14 @@ import (
// PIIHandlerOptions configures NewPIIHandler.
type PIIHandlerOptions struct {
// Redactor is the regex PII redactor. nil disables redaction.
Redactor *pii.Redactor
// DetectorsByHost maps an intercepted host (lower-cased) to the NER
// detector configs that should scan request bodies bound for it. The
// configs are resolved at listener-start from each host's owning
// model's pii.detectors + the detector models' pii_detection policy
// (a model-config edit needs a MITM restart, as hosts already do). A
// host absent from the map (or with an empty slice) is forwarded
// unredacted. Detector errors at request time fail closed.
DetectorsByHost map[string][]pii.NERConfig
// EventStore receives PIIEvent rows. nil discards events.
EventStore pii.EventStore
@@ -42,13 +47,6 @@ type PIIHandlerOptions struct {
// upstream URL. Identity by default; tests inject a httptest
// listener address.
DialHost func(host string) string
// HostsWithPIIDisabled lists destination hosts whose request
// bodies should NOT run through the redactor. TLS termination,
// upstream forwarding, and audit events still happen — only the
// regex pass is bypassed. Useful for telemetry/probe endpoints
// whose bodies aren't PII-shaped.
HostsWithPIIDisabled []string
}
func NewPIIHandler(opts PIIHandlerOptions) InterceptHandler {
@@ -76,16 +74,9 @@ func NewPIIHandler(opts PIIHandlerOptions) InterceptHandler {
dialHost = func(h string) string { return h }
}
patternAction := map[string]pii.Action{}
if opts.Redactor != nil {
for _, p := range opts.Redactor.Patterns() {
patternAction[p.ID] = p.Action
}
}
piiDisabled := make(map[string]bool, len(opts.HostsWithPIIDisabled))
for _, h := range opts.HostsWithPIIDisabled {
piiDisabled[strings.ToLower(strings.TrimSpace(h))] = true
detectorsByHost := make(map[string][]pii.NERConfig, len(opts.DetectorsByHost))
for h, cfgs := range opts.DetectorsByHost {
detectorsByHost[strings.ToLower(strings.TrimSpace(h))] = cfgs
}
d := &piiDispatcher{
@@ -96,26 +87,22 @@ func NewPIIHandler(opts PIIHandlerOptions) InterceptHandler {
// API keys such as Anthropic's x-api-key, which Go does NOT
// strip on cross-host redirects — to an unvetted host. Surface
// it as an error (handled as a 502) instead.
client: httpclient.New(httpclient.WithTransport(transport)),
redactor: opts.Redactor,
store: opts.EventStore,
patternAction: patternAction,
corrHeader: corrHeader,
dialHost: dialHost,
piiDisabled: piiDisabled,
client: httpclient.New(httpclient.WithTransport(transport)),
detectorsByHost: detectorsByHost,
store: opts.EventStore,
corrHeader: corrHeader,
dialHost: dialHost,
}
return d.serve
}
type piiDispatcher struct {
client *http.Client
redactor *pii.Redactor
store pii.EventStore
patternAction map[string]pii.Action
corrHeader string
dialHost func(host string) string
piiDisabled map[string]bool
eventSeq atomic.Uint64
client *http.Client
detectorsByHost map[string][]pii.NERConfig
store pii.EventStore
corrHeader string
dialHost func(host string) string
eventSeq atomic.Uint64
}
func (d *piiDispatcher) serve(w http.ResponseWriter, r *http.Request, host string) {
@@ -144,11 +131,17 @@ func (d *piiDispatcher) serve(w http.ResponseWriter, r *http.Request, host strin
}
shape := classifyRequestShape(host, r.URL.Path)
if d.redactor != nil && shape != shapeUnknown && !d.piiDisabled[strings.ToLower(host)] {
redacted, blocked, err := d.redactRequest(body, shape, correlationID)
cfgs := d.detectorsByHost[strings.ToLower(host)]
if len(cfgs) > 0 && shape != shapeUnknown {
redacted, blocked, err := d.redactRequest(r.Context(), body, shape, cfgs, correlationID)
switch {
case err != nil:
xlog.Debug("mitm: redact request failed; forwarding unchanged", "host", host, "path", r.URL.Path, "error", err)
// Fail closed: a detector outage must not silently forward the
// request unredacted — the operator configured this host's
// model with detectors precisely to catch this PII.
xlog.Error("mitm: NER redaction failed; blocking request (fail-closed)", "host", host, "path", r.URL.Path, "error", err)
writePIIBlocked(w, correlationID)
return
case blocked:
writePIIBlocked(w, correlationID)
return
@@ -185,12 +178,10 @@ func (d *piiDispatcher) serve(w http.ResponseWriter, r *http.Request, host strin
}
w.WriteHeader(resp.StatusCode)
// Response/output redaction is out of scope for now — the MITM proxy
// only scans request bodies (input). SSE responses pass through
// unmodified.
contentType := resp.Header.Get("Content-Type")
if shape != shapeUnknown && d.redactor != nil && isSSE(contentType) {
d.streamWithPII(w, resp.Body, shape, correlationID)
return
}
if isSSE(contentType) {
flusher, _ := w.(http.Flusher)
buf := make([]byte, 32*1024)
@@ -232,7 +223,7 @@ func classifyRequestShape(host, path string) requestShape {
return shapeUnknown
}
func (d *piiDispatcher) redactRequest(body []byte, shape requestShape, correlationID string) ([]byte, bool, error) {
func (d *piiDispatcher) redactRequest(ctx context.Context, body []byte, shape requestShape, cfgs []pii.NERConfig, correlationID string) ([]byte, bool, error) {
var parsed any
var adapter pii.Adapter
switch shape {
@@ -259,13 +250,21 @@ func (d *piiDispatcher) redactRequest(body []byte, shape requestShape, correlati
return body, false, nil
}
// One scan over the joined messages so the NER tier keeps
// conversational context (see pii.RedactNERSegments); results map
// back per message with local offsets.
segTexts := make([]string, len(texts))
for i, st := range texts {
segTexts[i] = st.Text
}
results, err := pii.RedactNERSegments(ctx, segTexts, cfgs)
if err != nil {
return nil, false, fmt.Errorf("ner detect: %w", err)
}
updates := make([]pii.ScannedText, 0, len(texts))
blocked := false
for _, st := range texts {
if st.Text == "" {
continue
}
res := d.redactor.RedactWithOverrides(st.Text, nil)
for i, res := range results {
if len(res.Spans) == 0 {
continue
}
@@ -273,7 +272,7 @@ func (d *piiDispatcher) redactRequest(body []byte, shape requestShape, correlati
if res.Blocked {
blocked = true
}
updates = append(updates, pii.ScannedText{Index: st.Index, Text: res.Redacted})
updates = append(updates, pii.ScannedText{Index: texts[i].Index, Text: res.Redacted})
}
if len(updates) > 0 {
@@ -295,13 +294,14 @@ func (d *piiDispatcher) recordEvents(spans []pii.Span, correlationID string) {
ev := pii.PIIEvent{
ID: fmt.Sprintf("mitm_%s_%d", correlationID, d.eventSeq.Add(1)),
Kind: pii.KindPII,
Origin: pii.OriginProxy,
CorrelationID: correlationID,
Direction: pii.DirectionIn,
PatternID: span.Pattern,
ByteOffset: span.Start,
Length: span.End - span.Start,
HashPrefix: span.HashPrefix,
Action: d.patternAction[span.Pattern],
Action: span.Action,
CreatedAt: time.Now(),
}
if err := d.store.Record(context.Background(), ev); err != nil {
@@ -310,49 +310,6 @@ func (d *piiDispatcher) recordEvents(spans []pii.Span, correlationID string) {
}
}
func (d *piiDispatcher) streamWithPII(w http.ResponseWriter, src io.Reader, shape requestShape, correlationID string) {
flusher, _ := w.(http.Flusher)
filter := pii.NewStreamFilter(d.redactor, nil, d.store, correlationID, "")
provider := ssewire.OpenAI
if shape == shapeAnthropicMessages {
provider = ssewire.Anthropic
}
emit := func(s string) {
_, _ = w.Write([]byte(s))
if flusher != nil {
flusher.Flush()
}
}
scanner := ssewire.NewScanner(src)
for scanner.Scan() {
ev := scanner.Event()
if ssewire.IsTerminalMarker(ev.DataLine, provider) {
if residual := filter.Drain(); residual != "" {
emit(ssewire.SynthResidualEvent(provider, residual))
}
emit(ev.Raw)
continue
}
out := ev.Raw
if ev.DataLine != "" {
rewritten, drop := ssewire.RewritePayload(ev.DataLine, provider, filter)
if drop {
continue
}
if rewritten != ev.DataLine {
out = strings.Replace(ev.Raw, ev.DataLine, rewritten, 1)
}
}
emit(out)
}
if residual := filter.Drain(); residual != "" {
emit(ssewire.SynthResidualEvent(provider, residual))
}
}
func writePIIBlocked(w http.ResponseWriter, correlationID string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)

View File

@@ -19,34 +19,58 @@ import (
. "github.com/onsi/gomega"
)
// startPIITestRig is the same shape as startMITMTestRig but plugs
// in the production PII handler instead of the passthrough fixture.
// The "host" the client thinks it's reaching is forced to
// api.anthropic.com so the request shape classifier matches.
// substringDetector is a deterministic pii.NERDetector for tests: it
// reports an entity for every occurrence of each configured substring,
// with byte offsets into the scanned text. Lets the MITM tests drive
// request redaction without a real token-classification backend.
type substringDetector struct{ groups map[string]string } // substring -> entity group
func (d substringDetector) Detect(_ context.Context, text string) ([]pii.NEREntity, error) {
var out []pii.NEREntity
for sub, group := range d.groups {
for idx := 0; ; {
i := strings.Index(text[idx:], sub)
if i < 0 {
break
}
start := idx + i
out = append(out, pii.NEREntity{Group: group, Start: start, End: start + len(sub), Score: 1})
idx = start + len(sub)
}
}
return out, nil
}
// testDetectorCfg flags emails (mask) and a known secret token (block).
func testDetectorCfg() pii.NERConfig {
return pii.NERConfig{
Detector: substringDetector{groups: map[string]string{
"alice@example.com": "EMAIL",
"bob@example.org": "EMAIL",
"sk-abcdefghijklmnopqrstuvwxyz1234": "PASSWORD",
}},
EntityActions: map[string]pii.Action{"EMAIL": pii.ActionMask, "PASSWORD": pii.ActionBlock},
}
}
// startPIITestRig plugs the production PII handler into a CONNECT proxy,
// with the upstream playing the role of api.anthropic.com. Request
// bodies bound for api.anthropic.com run through the NER detector above.
func startPIITestRig(upstream http.Handler) (*http.Client, string, *fakeStore, func()) {
// Upstream fake — plays the role of api.anthropic.com.
ts := httptest.NewTLSServer(upstream)
upstreamCertPool := x509.NewCertPool()
upstreamCertPool.AddCert(ts.Certificate())
upstreamURL, _ := url.Parse(ts.URL)
// Compiled patterns required for the redactor to actually fire
// (DefaultPatterns alone returns Pattern structs without regex).
patterns, err := pii.Compile(pii.DefaultPatterns())
ExpectWithOffset(1, err).NotTo(HaveOccurred())
redactor := pii.NewRedactor(patterns)
store := &fakeStore{}
ca, err := NewInMemoryCA()
ExpectWithOffset(1, err).NotTo(HaveOccurred())
// DialHost remaps the upstream dial target to the httptest
// fake while leaving the classifier-facing host
// ("api.anthropic.com") untouched. ServerName=example.com is
// what httptest.NewTLSServer issues its cert for.
upstreamHost := upstreamURL.Host
prodHandler := NewPIIHandler(PIIHandlerOptions{
Redactor: redactor,
DetectorsByHost: map[string][]pii.NERConfig{
"api.anthropic.com": {testDetectorCfg()},
},
EventStore: store,
UpstreamTLS: &tls.Config{
RootCAs: upstreamCertPool,
@@ -79,8 +103,6 @@ func startPIITestRig(upstream http.Handler) (*http.Client, string, *fakeStore, f
srv.Stop()
ts.Close()
}
// We point requests at api.anthropic.com so classifyRequestShape
// matches; the wrappedHandler retargets to the upstream fake.
return client, "https://api.anthropic.com", store, cleanup
}
@@ -101,7 +123,7 @@ func (s *fakeStore) Close() error { return nil }
func (s *fakeStore) recorded() int { return len(s.events) }
var _ = Describe("PIIHandler", func() {
It("redacts request email", func() {
It("redacts request email via NER", func() {
var receivedBody []byte
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBody, _ = io.ReadAll(r.Body)
@@ -119,15 +141,11 @@ var _ = Describe("PIIHandler", func() {
Expect(resp.StatusCode).To(Equal(200))
Expect(string(receivedBody)).NotTo(ContainSubstring("alice@example.com"), "upstream received unredacted body")
Expect(string(receivedBody)).To(ContainSubstring("[REDACTED:email]"), "upstream did not see redaction marker")
Expect(string(receivedBody)).To(ContainSubstring("[REDACTED:ner:EMAIL]"), "upstream did not see redaction marker")
Expect(store.recorded()).NotTo(BeZero(), "no PIIEvent recorded for the email match")
})
It("refuses to follow an upstream redirect", func() {
// A 3xx from the upstream would otherwise be followed, replaying
// the request (and its provider API key, e.g. Anthropic's
// x-api-key which Go does NOT strip on cross-host redirects) to
// the Location host. The refused redirect surfaces as a 502.
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://evil.example.com/steal", http.StatusFound)
})
@@ -142,7 +160,7 @@ var _ = Describe("PIIHandler", func() {
Expect(resp.StatusCode).To(Equal(http.StatusBadGateway), "refused redirect must surface as 502, not be followed")
})
It("blocks api key in request", func() {
It("blocks a detected secret in the request", func() {
upstreamCalled := false
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
@@ -156,46 +174,13 @@ var _ = Describe("PIIHandler", func() {
resp, err := client.Post(base+"/v1/messages", "application/json", strings.NewReader(body))
Expect(err).NotTo(HaveOccurred(), "client.Post")
defer func() { _ = resp.Body.Close() }()
Expect(resp.StatusCode).To(Equal(400), "api_key_prefix has Block default")
Expect(resp.StatusCode).To(Equal(400), "PASSWORD entity action is block")
Expect(upstreamCalled).To(BeFalse(), "upstream was called despite block — proxy should short-circuit")
body2, _ := io.ReadAll(resp.Body)
Expect(string(body2)).To(ContainSubstring("pii_blocked"))
})
It("streaming redaction", func() {
// Anthropic-shape SSE; "alice@" + "example.com" splits the
// email across chunks so the StreamFilter has to buffer.
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
flusher := w.(http.Flusher)
chunks := []string{
`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"contact me at alice@"}}`,
`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"example.com any time"}}`,
`{"type":"message_stop"}`,
}
for _, c := range chunks {
_, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", "content_block_delta", c)
flusher.Flush()
}
})
client, base, _, cleanup := startPIITestRig(upstream)
defer cleanup()
body := `{"model":"claude-3-5-sonnet","max_tokens":100,"stream":true,"messages":[{"role":"user","content":"hi"}]}`
resp, err := client.Post(base+"/v1/messages", "application/json", strings.NewReader(body))
Expect(err).NotTo(HaveOccurred(), "Post")
defer func() { _ = resp.Body.Close() }()
out, _ := io.ReadAll(resp.Body)
outStr := string(out)
Expect(outStr).NotTo(ContainSubstring("alice@example.com"), "email leaked through MITM stream")
Expect(outStr).To(ContainSubstring("[REDACTED:email]"), "redaction marker missing from MITM stream")
})
It("non-chat path passes through", func() {
// A path the classifier doesn't recognise (e.g. an OAuth
// callback) must forward the body verbatim, no PII parsing.
var receivedBody []byte
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBody, _ = io.ReadAll(r.Body)
@@ -216,14 +201,12 @@ var _ = Describe("PIIHandler", func() {
var _ = Describe("redactRequest", func() {
It("handles anthropic shape", func() {
patterns, _ := pii.Compile(pii.DefaultPatterns())
r := pii.NewRedactor(patterns)
body := []byte(`{"model":"claude","max_tokens":10,"messages":[{"role":"user","content":"reach me at bob@example.org"}]}`)
d := &piiDispatcher{redactor: r, patternAction: map[string]pii.Action{}}
out, blocked, err := d.redactRequest(body, shapeAnthropicMessages, "corr-1")
d := &piiDispatcher{}
out, blocked, err := d.redactRequest(context.Background(), body, shapeAnthropicMessages, []pii.NERConfig{testDetectorCfg()}, "corr-1")
Expect(err).NotTo(HaveOccurred())
Expect(blocked).To(BeFalse(), "email is mask, not block — blocked should be false")
Expect(blocked).To(BeFalse(), "EMAIL is mask, not block — blocked should be false")
var parsed map[string]any
Expect(json.Unmarshal(out, &parsed)).To(Succeed())
msgs := parsed["messages"].([]any)
@@ -273,9 +256,6 @@ var _ = Describe("Proxy events", func() {
})
It("tunneled host emits connect event only", func() {
// A non-allowlisted CONNECT must record a proxy_connect with
// Intercepted=false and NOT a proxy_traffic event (tunneled
// bytes never reach the dispatcher).
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, "passthrough")
})