Skip to content

Commit 06ff56e

Browse files
feat(pii): restore request-scoped pseudonyms (#11272)
* feat(pii): restore request-scoped pseudonyms Replace masked request values with unique per-request tokens when response restoration is enabled, then restore them across JSON and SSE write boundaries. Document the opt-in model setting and expose it in config metadata.\n\nAssisted-by: Codex:gpt-5 * fix(pii): wrap reversible redaction tokens Use configurable token delimiters to avoid restoring ordinary model text that happens to match an internal identifier. Rename the option and document the confidentiality tradeoff. Assisted-by: Codex:gpt-5 --------- Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
1 parent a0f50b2 commit 06ff56e

6 files changed

Lines changed: 276 additions & 6 deletions

File tree

core/config/meta/registry.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,25 @@ func DefaultRegistry() map[string]FieldMetaOverride {
816816
AutocompleteProvider: "models:token_classify",
817817
Order: 201,
818818
},
819+
"pii.reversible_redactions": {
820+
Section: "pii",
821+
Label: "Reversible Redactions",
822+
Description: "Replace masked values with wrapped request-scoped tokens and restore them when the model returns those tokens. Supports streaming responses and never persists the substitution map.",
823+
Component: "toggle",
824+
Order: 202,
825+
},
826+
"pii.reversible_token_prefix": {
827+
Section: "pii",
828+
Label: "Reversible Token Prefix",
829+
Description: "Prefix for reversible redaction tokens. Defaults to [REDACTED:.",
830+
Order: 203,
831+
},
832+
"pii.reversible_token_suffix": {
833+
Section: "pii",
834+
Label: "Reversible Token Suffix",
835+
Description: "Suffix for reversible redaction tokens. Defaults to ].",
836+
Order: 204,
837+
},
819838

820839
// --- PII detection policy (on a token_classify detector model) ---
821840
"pii_detection.min_score": {

core/config/model_config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,8 +440,18 @@ type PIIConfig struct {
440440
// model just opts in by listing detectors. Multiple detectors union
441441
// their hits; overlapping spans resolve to the strongest action.
442442
Detectors []string `yaml:"detectors,omitempty" json:"detectors,omitempty"`
443+
444+
// ReversibleRedactions replaces request PII with stable, request-scoped
445+
// tokens and restores those values when the wrapped tokens appear in the response.
446+
ReversibleRedactions bool `yaml:"reversible_redactions,omitempty" json:"reversible_redactions,omitempty"`
447+
ReversibleTokenPrefix string `yaml:"reversible_token_prefix,omitempty" json:"reversible_token_prefix,omitempty"`
448+
ReversibleTokenSuffix string `yaml:"reversible_token_suffix,omitempty" json:"reversible_token_suffix,omitempty"`
443449
}
444450

451+
func (c ModelConfig) PIIReversibleRedactions() bool { return c.PII.ReversibleRedactions }
452+
func (c ModelConfig) PIIReversibleTokenPrefix() string { return c.PII.ReversibleTokenPrefix }
453+
func (c ModelConfig) PIIReversibleTokenSuffix() string { return c.PII.ReversibleTokenSuffix }
454+
445455
// @Description Detection policy for a token-classification (NER) model
446456
// used as a PII detector. Lives on the detector model's own config so the
447457
// model is a self-describing policy unit: consuming models reference it by

core/services/routing/pii/middleware.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,16 @@ func RequestMiddleware(redactor *Redactor, store EventStore, adapter Adapter, fa
194194

195195
texts := adapter.Scan(parsed)
196196
updates := make([]ScannedText, 0, len(texts))
197+
prefix, suffix := defaultReversibleTokenPrefix, defaultReversibleTokenSuffix
198+
if cfg, ok := rawCfg.(responsePIIConfig); ok {
199+
if cfg.PIIReversibleTokenPrefix() != "" {
200+
prefix = cfg.PIIReversibleTokenPrefix()
201+
}
202+
if cfg.PIIReversibleTokenSuffix() != "" {
203+
suffix = cfg.PIIReversibleTokenSuffix()
204+
}
205+
}
206+
pseudonyms := newPseudonymizer(prefix, suffix)
197207
var blocked bool
198208
var firstEventID string
199209

@@ -259,7 +269,11 @@ func RequestMiddleware(redactor *Redactor, store EventStore, adapter Adapter, fa
259269
if res.Blocked {
260270
blocked = true
261271
}
262-
updates = append(updates, ScannedText{Index: st.Index, Text: res.Redacted})
272+
redacted := res.Redacted
273+
if cfg, ok := rawCfg.(responsePIIConfig); ok && cfg.PIIReversibleRedactions() {
274+
redacted = pseudonyms.replace(st.Text, res.Spans)
275+
}
276+
updates = append(updates, ScannedText{Index: st.Index, Text: redacted})
263277
}
264278

265279
if blocked {
@@ -279,7 +293,16 @@ func RequestMiddleware(redactor *Redactor, store EventStore, adapter Adapter, fa
279293
if firstEventID != "" {
280294
c.Set(ctxKeyPIIEventID, firstEventID)
281295
}
282-
return next(c)
296+
if len(pseudonyms.original) == 0 {
297+
return next(c)
298+
}
299+
writer := newRestoringWriter(c.Response().Writer, pseudonyms.original)
300+
c.Response().Writer = writer
301+
err := next(c)
302+
if finishErr := writer.Finish(); err == nil {
303+
err = finishErr
304+
}
305+
return err
283306
}
284307
}
285308
}

core/services/routing/pii/middleware_test.go

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,16 @@ func setRequestOnContext(req *fakeRequest) echo.MiddlewareFunc {
6060
type fakeModelPIIConfig struct {
6161
enabled bool
6262
detectors []string
63+
reverse bool
64+
prefix string
65+
suffix string
6366
}
6467

65-
func (f fakeModelPIIConfig) PIIIsEnabled() bool { return f.enabled }
66-
func (f fakeModelPIIConfig) PIIDetectors() []string { return f.detectors }
68+
func (f fakeModelPIIConfig) PIIIsEnabled() bool { return f.enabled }
69+
func (f fakeModelPIIConfig) PIIDetectors() []string { return f.detectors }
70+
func (f fakeModelPIIConfig) PIIReversibleRedactions() bool { return f.reverse }
71+
func (f fakeModelPIIConfig) PIIReversibleTokenPrefix() string { return f.prefix }
72+
func (f fakeModelPIIConfig) PIIReversibleTokenSuffix() string { return f.suffix }
6773

6874
func withModelConfig(cfg fakeModelPIIConfig) echo.MiddlewareFunc {
6975
return func(next echo.HandlerFunc) echo.HandlerFunc {
@@ -129,6 +135,58 @@ var _ = Describe("RequestMiddleware (NER)", func() {
129135
Expect(events[0].Direction).To(Equal(DirectionIn))
130136
})
131137

138+
It("restores distinct pseudonyms across streaming write boundaries", func() {
139+
body := &fakeRequest{Messages: []string{"Email alice@example.com or bob@example.com"}}
140+
mw := RequestMiddleware(&Redactor{}, store(), fakeAdapter(), nil,
141+
WithNERResolver(resolverFor(map[string]NERConfig{
142+
"privacy-filter": nerCfg(ActionMask,
143+
NEREntity{Group: "EMAIL", Start: 6, End: 23, Score: 0.95},
144+
NEREntity{Group: "EMAIL", Start: 27, End: 42, Score: 0.95}),
145+
})))
146+
e := echo.New()
147+
e.POST("/chat", func(c echo.Context) error {
148+
Expect(body.Messages[0]).To(Equal("Email [REDACTED:EMAIL_001] or [REDACTED:EMAIL_002]"))
149+
_, err := c.Response().Write([]byte(`data: {"delta":"EMAIL_001 and [REDACTED:EMAIL_0`))
150+
Expect(err).ToNot(HaveOccurred())
151+
_, err = c.Response().Write([]byte(`01] and [REDACTED:EMAIL_002]"}` + "\n\n"))
152+
return err
153+
}, setRequestOnContext(body), withModelConfig(fakeModelPIIConfig{
154+
enabled: true, detectors: []string{"privacy-filter"}, reverse: true,
155+
}), mw)
156+
157+
req := httptest.NewRequest(http.MethodPost, "/chat", strings.NewReader(`{}`))
158+
w := httptest.NewRecorder()
159+
e.ServeHTTP(w, req)
160+
161+
Expect(w.Code).To(Equal(http.StatusOK))
162+
Expect(w.Body.String()).To(Equal("data: {\"delta\":\"EMAIL_001 and alice@example.com and bob@example.com\"}\n\n"))
163+
})
164+
165+
It("uses configured reversible redaction token delimiters", func() {
166+
body := &fakeRequest{Messages: []string{"Email alice@example.com"}}
167+
mw := RequestMiddleware(&Redactor{}, store(), fakeAdapter(), nil,
168+
WithNERResolver(resolverFor(map[string]NERConfig{
169+
"privacy-filter": nerCfg(ActionMask,
170+
NEREntity{Group: "EMAIL", Start: 6, End: 23, Score: 0.95}),
171+
})))
172+
e := echo.New()
173+
e.POST("/chat", func(c echo.Context) error {
174+
Expect(body.Messages[0]).To(Equal("Email <PII:EMAIL_001>"))
175+
_, err := c.Response().Write([]byte(`{"text":"<PII:EMAIL_001>"}`))
176+
return err
177+
}, setRequestOnContext(body), withModelConfig(fakeModelPIIConfig{
178+
enabled: true, detectors: []string{"privacy-filter"}, reverse: true,
179+
prefix: "<PII:", suffix: ">",
180+
}), mw)
181+
182+
req := httptest.NewRequest(http.MethodPost, "/chat", strings.NewReader(`{}`))
183+
w := httptest.NewRecorder()
184+
e.ServeHTTP(w, req)
185+
186+
Expect(w.Code).To(Equal(http.StatusOK))
187+
Expect(w.Body.String()).To(Equal(`{"text":"alice@example.com"}`))
188+
})
189+
132190
It("blocks (400) when a detected entity's action is block", func() {
133191
st := store()
134192
body := &fakeRequest{Messages: []string{"my password is hunter2 ok"}}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package pii
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
"unicode"
9+
)
10+
11+
type responsePIIConfig interface {
12+
PIIReversibleRedactions() bool
13+
PIIReversibleTokenPrefix() string
14+
PIIReversibleTokenSuffix() string
15+
}
16+
17+
const (
18+
defaultReversibleTokenPrefix = "[REDACTED:"
19+
defaultReversibleTokenSuffix = "]"
20+
)
21+
22+
type pseudonymizer struct {
23+
byValue map[string]string
24+
original map[string]string
25+
counts map[string]int
26+
prefix string
27+
suffix string
28+
}
29+
30+
func newPseudonymizer(prefix, suffix string) *pseudonymizer {
31+
return &pseudonymizer{
32+
byValue: map[string]string{},
33+
original: map[string]string{},
34+
counts: map[string]int{},
35+
prefix: prefix,
36+
suffix: suffix,
37+
}
38+
}
39+
40+
func (p *pseudonymizer) replace(text string, spans []Span) string {
41+
var b strings.Builder
42+
last := 0
43+
for _, span := range spans {
44+
if span.Action != ActionMask || span.Start < last || span.End > len(text) {
45+
continue
46+
}
47+
b.WriteString(text[last:span.Start])
48+
value := text[span.Start:span.End]
49+
token, ok := p.byValue[value]
50+
if !ok {
51+
group := pseudonymGroup(span.Pattern)
52+
p.counts[group]++
53+
token = fmt.Sprintf("%s%s_%03d%s", p.prefix, group, p.counts[group], p.suffix)
54+
p.byValue[value] = token
55+
p.original[token] = value
56+
}
57+
b.WriteString(token)
58+
last = span.End
59+
}
60+
b.WriteString(text[last:])
61+
return b.String()
62+
}
63+
64+
func pseudonymGroup(pattern string) string {
65+
if i := strings.LastIndexByte(pattern, ':'); i >= 0 {
66+
pattern = pattern[i+1:]
67+
}
68+
var b strings.Builder
69+
for _, r := range strings.ToUpper(pattern) {
70+
if unicode.IsLetter(r) || unicode.IsDigit(r) {
71+
b.WriteRune(r)
72+
} else {
73+
b.WriteByte('_')
74+
}
75+
}
76+
if b.Len() == 0 {
77+
return "PII"
78+
}
79+
return b.String()
80+
}
81+
82+
type restoringWriter struct {
83+
http.ResponseWriter
84+
pending string
85+
replacements map[string]string
86+
}
87+
88+
func newRestoringWriter(w http.ResponseWriter, originals map[string]string) *restoringWriter {
89+
replacements := make(map[string]string, len(originals))
90+
for token, original := range originals {
91+
encoded, _ := json.Marshal(original)
92+
replacements[token] = string(encoded[1 : len(encoded)-1])
93+
}
94+
return &restoringWriter{ResponseWriter: w, replacements: replacements}
95+
}
96+
97+
func (w *restoringWriter) Write(data []byte) (int, error) {
98+
w.pending += string(data)
99+
ready, pending := w.splitReady(w.replace(w.pending))
100+
w.pending = pending
101+
if ready != "" {
102+
if _, err := w.ResponseWriter.Write([]byte(ready)); err != nil {
103+
return 0, err
104+
}
105+
}
106+
return len(data), nil
107+
}
108+
109+
func (w *restoringWriter) Flush() {
110+
if f, ok := w.ResponseWriter.(http.Flusher); ok {
111+
f.Flush()
112+
}
113+
}
114+
115+
func (w *restoringWriter) Finish() error {
116+
if w.pending == "" {
117+
return nil
118+
}
119+
_, err := w.ResponseWriter.Write([]byte(w.replace(w.pending)))
120+
w.pending = ""
121+
return err
122+
}
123+
124+
func (w *restoringWriter) replace(s string) string {
125+
for token, original := range w.replacements {
126+
s = strings.ReplaceAll(s, token, original)
127+
}
128+
return s
129+
}
130+
131+
func (w *restoringWriter) splitReady(s string) (string, string) {
132+
keep := 0
133+
for token := range w.replacements {
134+
limit := min(len(token)-1, len(s))
135+
for n := 1; n <= limit; n++ {
136+
if strings.HasSuffix(s, token[:n]) && n > keep {
137+
keep = n
138+
}
139+
}
140+
}
141+
return s[:len(s)-keep], s[len(s)-keep:]
142+
}

docs/content/operations/middleware.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,15 +165,33 @@ pii:
165165
enabled: true # default-on for cloud-proxy; explicit for audit
166166
detectors:
167167
- privacy-filter-multilingual
168+
reversible_redactions: true # restore request PII if the model echoes its wrapped token
169+
reversible_token_prefix: "[REDACTED:" # optional; this is the default
170+
reversible_token_suffix: "]" # optional; this is the default
168171
```
169172

173+
`reversible_redactions` enables bijective, request-scoped replacement. Each
174+
masked value is sent to the backend as a stable wrapped token such as
175+
`[REDACTED:EMAIL_001]` instead of a generic redaction marker. If the model includes that token in
176+
its response, LocalAI restores the original value before returning JSON or SSE
177+
to the caller. The substitution map exists only for that request and is never
178+
logged or persisted. Leave the option unset (the default) for irreversible
179+
`[REDACTED:...]` masking.
180+
181+
The prefix and suffix reduce collisions with ordinary model output and can be
182+
customized with `reversible_token_prefix` and `reversible_token_suffix`.
183+
Reversible redactions provide less confidentiality than irreversible masking:
184+
any third party that can observe both the redacted request and restored response
185+
may be able to infer the original values.
186+
170187
Multiple detectors **union** their detections; overlapping spans resolve to
171188
the strongest action (`block` > `mask` > `allow`). A configured detector
172189
that can't be loaded **fails the request closed** (HTTP 503,
173190
`error.type=pii_ner_unavailable`) rather than silently skipping the check.
174191
The same NER path runs on the [MITM proxy]({{< relref "mitm-proxy.md" >}})
175-
request body for intercepted hosts. Response/output redaction is out of
176-
scope for now.
192+
request body for intercepted hosts. Reversible response restoration currently
193+
applies to LocalAI API routes; the MITM proxy keeps its own output-redaction
194+
policy.
177195

178196
### Instance-wide default detector
179197

0 commit comments

Comments
 (0)