Skip to content

Commit 54e7666

Browse files
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
1 parent c0a9c42 commit 54e7666

6 files changed

Lines changed: 203 additions & 6 deletions

File tree

core/config/meta/registry.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,13 @@ func DefaultRegistry() map[string]FieldMetaOverride {
816816
AutocompleteProvider: "models:token_classify",
817817
Order: 201,
818818
},
819+
"pii.reverse_in_response": {
820+
Section: "pii",
821+
Label: "Restore PII In Response",
822+
Description: "Replace masked values with request-scoped pseudonyms and restore them when the model returns those pseudonyms. Supports streaming responses and never persists the substitution map.",
823+
Component: "toggle",
824+
Order: 202,
825+
},
819826

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

core/config/model_config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,8 +440,14 @@ 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+
// ReverseInResponse replaces request PII with stable, request-scoped
445+
// pseudonyms and restores those values when they appear in the response.
446+
ReverseInResponse bool `yaml:"reverse_in_response,omitempty" json:"reverse_in_response,omitempty"`
443447
}
444448

449+
func (c ModelConfig) PIIReverseInResponse() bool { return c.PII.ReverseInResponse }
450+
445451
// @Description Detection policy for a token-classification (NER) model
446452
// used as a PII detector. Lives on the detector model's own config so the
447453
// model is a self-describing policy unit: consuming models reference it by

core/services/routing/pii/middleware.go

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

195195
texts := adapter.Scan(parsed)
196196
updates := make([]ScannedText, 0, len(texts))
197+
pseudonyms := newPseudonymizer()
197198
var blocked bool
198199
var firstEventID string
199200

@@ -259,7 +260,11 @@ func RequestMiddleware(redactor *Redactor, store EventStore, adapter Adapter, fa
259260
if res.Blocked {
260261
blocked = true
261262
}
262-
updates = append(updates, ScannedText{Index: st.Index, Text: res.Redacted})
263+
redacted := res.Redacted
264+
if cfg, ok := rawCfg.(responsePIIConfig); ok && cfg.PIIReverseInResponse() {
265+
redacted = pseudonyms.replace(st.Text, res.Spans)
266+
}
267+
updates = append(updates, ScannedText{Index: st.Index, Text: redacted})
263268
}
264269

265270
if blocked {
@@ -279,7 +284,16 @@ func RequestMiddleware(redactor *Redactor, store EventStore, adapter Adapter, fa
279284
if firstEventID != "" {
280285
c.Set(ctxKeyPIIEventID, firstEventID)
281286
}
282-
return next(c)
287+
if len(pseudonyms.original) == 0 {
288+
return next(c)
289+
}
290+
writer := newRestoringWriter(c.Response().Writer, pseudonyms.original)
291+
c.Response().Writer = writer
292+
err := next(c)
293+
if finishErr := writer.Finish(); err == nil {
294+
err = finishErr
295+
}
296+
return err
283297
}
284298
}
285299
}

core/services/routing/pii/middleware_test.go

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

65-
func (f fakeModelPIIConfig) PIIIsEnabled() bool { return f.enabled }
66-
func (f fakeModelPIIConfig) PIIDetectors() []string { return f.detectors }
66+
func (f fakeModelPIIConfig) PIIIsEnabled() bool { return f.enabled }
67+
func (f fakeModelPIIConfig) PIIDetectors() []string { return f.detectors }
68+
func (f fakeModelPIIConfig) PIIReverseInResponse() bool { return f.reverse }
6769

6870
func withModelConfig(cfg fakeModelPIIConfig) echo.MiddlewareFunc {
6971
return func(next echo.HandlerFunc) echo.HandlerFunc {
@@ -129,6 +131,33 @@ var _ = Describe("RequestMiddleware (NER)", func() {
129131
Expect(events[0].Direction).To(Equal(DirectionIn))
130132
})
131133

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

docs/content/operations/middleware.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,15 +165,25 @@ pii:
165165
enabled: true # default-on for cloud-proxy; explicit for audit
166166
detectors:
167167
- privacy-filter-multilingual
168+
reverse_in_response: true # restore request PII if the model echoes its pseudonym
168169
```
169170

171+
`reverse_in_response` enables bijective, request-scoped replacement. Each
172+
masked value is sent to the backend as a stable pseudonym such as `EMAIL_001`
173+
instead of a generic redaction marker. If the model includes that pseudonym in
174+
its response, LocalAI restores the original value before returning JSON or SSE
175+
to the caller. The substitution map exists only for that request and is never
176+
logged or persisted. Leave the option unset (the default) for irreversible
177+
`[REDACTED:...]` masking.
178+
170179
Multiple detectors **union** their detections; overlapping spans resolve to
171180
the strongest action (`block` > `mask` > `allow`). A configured detector
172181
that can't be loaded **fails the request closed** (HTTP 503,
173182
`error.type=pii_ner_unavailable`) rather than silently skipping the check.
174183
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.
184+
request body for intercepted hosts. Bijective response restoration currently
185+
applies to LocalAI API routes; the MITM proxy keeps its own output-redaction
186+
policy.
177187

178188
### Instance-wide default detector
179189

0 commit comments

Comments
 (0)