From 34408fcfe152fe1519d749eb189db7c4d9024801 Mon Sep 17 00:00:00 2001 From: Stefan Walcz Date: Thu, 15 Oct 2026 10:50:07 +0200 Subject: [PATCH 1/3] feat(pii): apply PII middleware to the Open Responses API The per-model PII filter ran on /v1/chat/completions, /v1/completions, /v1/messages and the Ollama routes, but not on POST /v1/responses. A model whose policy blocks or masks a secret on the chat route served the same content unfiltered when it was sent through the Responses API. Add a piiadapter.OpenResponses adapter and wire pii.RequestMiddleware into the POST /v1/responses and /responses chain, after the model config is resolved and merged and before the handler converts input items into prompt messages. The adapter covers instructions, input as a string, and in an input item array the text of message and reasoning items (string content or input_text/output_text parts, including typeless {role, content} items), function_call arguments and function_call_output output; structured tool output is scanned as the JSON the handler forwards. Image, file, audio and video parts are left untouched. The Responses WebSocket mode (GET /v1/responses) is not HTTP middleware and cannot filter, so a response.create for a model with PII filtering enabled is now refused with pii_unsupported_transport instead of being served unfiltered. The docs list the new route and this limit. Tests: adapter specs for every covered field, each with a clean-text counter-check; a route spec driving the real middleware chain with an in-process pattern detector (block on string input, content part, instructions and tool output; mask; clean input passes unchanged); and WebSocket specs proving the refusal and that models without a PII policy are still served. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Stefan Walcz --- .../http/endpoints/openresponses/websocket.go | 23 ++- .../openresponses/websocket_pii_test.go | 79 ++++++++ core/http/routes/openresponses.go | 35 +++- core/http/routes/openresponses_pii_test.go | 134 ++++++++++++++ .../routing/piiadapter/openresponses.go | 172 ++++++++++++++++++ .../routing/piiadapter/openresponses_test.go | 169 +++++++++++++++++ docs/content/operations/middleware.md | 19 +- 7 files changed, 612 insertions(+), 19 deletions(-) create mode 100644 core/http/endpoints/openresponses/websocket_pii_test.go create mode 100644 core/http/routes/openresponses_pii_test.go create mode 100644 core/services/routing/piiadapter/openresponses.go create mode 100644 core/services/routing/piiadapter/openresponses_test.go diff --git a/core/http/endpoints/openresponses/websocket.go b/core/http/endpoints/openresponses/websocket.go index f0cb4b3314bf..56f229e9dab5 100644 --- a/core/http/endpoints/openresponses/websocket.go +++ b/core/http/endpoints/openresponses/websocket.go @@ -16,6 +16,7 @@ import ( "github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/core/http/middleware" "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/routing/pii" "github.com/mudler/LocalAI/core/templates" "github.com/mudler/LocalAI/pkg/functions" "github.com/mudler/LocalAI/pkg/model" @@ -72,6 +73,7 @@ func WebSocketEndpoint(application *application.Application) echo.HandlerFunc { ml := application.ModelLoader() evaluator := application.TemplatesEvaluator() appConfig := application.ApplicationConfig() + piiPolicy := application.PIIPolicyResolver() return func(c echo.Context) error { owner := ownerFromContext(c) @@ -96,13 +98,13 @@ func WebSocketEndpoint(application *application.Application) echo.HandlerFunc { xlog.Debug("WebSocket Responses connection established", "address", ws.RemoteAddr().String()) - handleWebSocketConnection(connCtx, conn, owner, cl, ml, evaluator, appConfig) + handleWebSocketConnection(connCtx, conn, owner, cl, ml, evaluator, appConfig, piiPolicy) return nil } } // handleWebSocketConnection runs the read loop for a single WebSocket connection. -func handleWebSocketConnection(connCtx context.Context, conn *lockedConn, owner string, cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig) { +func handleWebSocketConnection(connCtx context.Context, conn *lockedConn, owner string, cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig, piiPolicy pii.PolicyResolver) { // Responses created with store=false remain available only to this WebSocket // connection, matching the Responses WebSocket continuation contract without // leaking zero-data-retention state into the process-wide response store. @@ -172,7 +174,7 @@ func handleWebSocketConnection(connCtx context.Context, conn *lockedConn, owner releaseOnce.Do(inflight.Unlock) } defer release() - handleWSResponseCreate(connCtx, conn, connectionStore, release, owner, wsMsg.Generate, &wsMsg.OpenResponsesRequest, cl, ml, evaluator, appConfig) + handleWSResponseCreate(connCtx, conn, connectionStore, release, owner, wsMsg.Generate, &wsMsg.OpenResponsesRequest, cl, ml, evaluator, appConfig, piiPolicy) }() } } @@ -181,7 +183,7 @@ func handleWebSocketConnection(connCtx context.Context, conn *lockedConn, owner // It reuses the existing background stream infrastructure: the request is processed via // handleBackgroundStream which buffers events into the store, and a forwarder goroutine // reads those events and sends them over the WebSocket. -func handleWSResponseCreate(connCtx context.Context, conn *lockedConn, connectionStore *ResponseStore, release func(), owner string, generate *bool, input *schema.OpenResponsesRequest, cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig) { +func handleWSResponseCreate(connCtx context.Context, conn *lockedConn, connectionStore *ResponseStore, release func(), owner string, generate *bool, input *schema.OpenResponsesRequest, cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig, piiPolicy pii.PolicyResolver) { createdAt := time.Now().Unix() responseID := fmt.Sprintf("resp_%s", uuid.New().String()) fail := func(errType, message, param string) { @@ -213,6 +215,19 @@ func handleWSResponseCreate(connCtx context.Context, conn *lockedConn, connectio return } + // The PII request middleware is HTTP middleware and never sees messages + // arriving over this socket. Rather than serve a PII-enabled model + // unfiltered (the same content would be blocked or masked on + // POST /v1/responses), refuse it here: fail closed, as the middleware + // does when its detector is unavailable. + if piiPolicy != nil { + if enabled, detectors := piiPolicy(cfg); enabled && len(detectors) > 0 { + xlog.Warn("WebSocket Responses: refusing PII-enabled model, PII filtering is not applied on the WebSocket transport", "model", input.Model) + fail("pii_unsupported_transport", "PII filtering is enabled for this model and is not available over the Responses WebSocket; use POST /v1/responses", "model") + return + } + } + // Set up context with cancellation tied to connection lifetime reqCtx, reqCancel := context.WithCancel(connCtx) defer reqCancel() diff --git a/core/http/endpoints/openresponses/websocket_pii_test.go b/core/http/endpoints/openresponses/websocket_pii_test.go new file mode 100644 index 000000000000..824cf0fdfa72 --- /dev/null +++ b/core/http/endpoints/openresponses/websocket_pii_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +package openresponses + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/gorilla/websocket" + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/routing/pii" + "github.com/mudler/LocalAI/pkg/system" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The PII request middleware is HTTP middleware, so messages arriving over +// the Responses WebSocket never pass through it. These specs drive the real +// socket loop and prove a PII-enabled model is refused there instead of +// being served unfiltered. +var _ = Describe("WebSocket Responses PII guard", func() { + // dial runs the real connection loop behind a test server with the given + // policy and returns the first event answering one prewarm request + // (generate=false, so no backend is needed). + dial := func(policy pii.PolicyResolver) schema.ORStreamEvent { + GinkgoHelper() + dir := GinkgoT().TempDir() + appCfg := config.NewApplicationConfig() + appCfg.SystemState = &system.SystemState{Model: system.Model{ModelsPath: dir}} + cl := config.NewModelConfigLoader(dir) + m := config.ModelConfig{Name: "m", Backend: "test"} + m.Model = "m.gguf" + cl.ReplaceModelConfigs([]config.ModelConfig{m}) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ws, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer ws.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + handleWebSocketConnection(ctx, &lockedConn{Conn: ws}, "owner", cl, nil, nil, appCfg, policy) + })) + DeferCleanup(srv.Close) + + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(conn.Close) + Expect(conn.SetReadDeadline(time.Now().Add(10 * time.Second))).To(Succeed()) + Expect(conn.WriteMessage(websocket.TextMessage, + []byte(`{"type":"response.create","generate":false,"store":false,"model":"m","input":"my key is sk-secret"}`))).To(Succeed()) + + var ev schema.ORStreamEvent + Expect(conn.ReadJSON(&ev)).To(Succeed()) + return ev + } + + It("refuses a model whose PII policy is enabled", func() { + ev := dial(func(any) (bool, []string) { return true, []string{"det"} }) + Expect(ev.Type).To(Equal("error")) + Expect(ev.Error).ToNot(BeNil()) + Expect(ev.Error.Type).To(Equal("pii_unsupported_transport")) + Expect(ev.Error.Message).ToNot(ContainSubstring("sk-secret"), "the refusal must not echo the request content") + }) + + It("serves a model without a PII policy", func() { + ev := dial(func(any) (bool, []string) { return false, nil }) + Expect(ev.Type).To(Equal("response.created")) + }) + + It("serves a PII-enabled model with no detectors, as the HTTP middleware does", func() { + ev := dial(func(any) (bool, []string) { return true, nil }) + Expect(ev.Type).To(Equal("response.created")) + }) +}) diff --git a/core/http/routes/openresponses.go b/core/http/routes/openresponses.go index 101567a6a683..2af1467408e8 100644 --- a/core/http/routes/openresponses.go +++ b/core/http/routes/openresponses.go @@ -9,6 +9,8 @@ import ( "github.com/mudler/LocalAI/core/http/endpoints/openresponses" "github.com/mudler/LocalAI/core/http/middleware" "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/routing/pii" + "github.com/mudler/LocalAI/core/services/routing/piiadapter" "github.com/mudler/xlog" ) @@ -41,16 +43,7 @@ func RegisterOpenResponsesRoutes(app *echo.Echo, agentControl, ) - responsesMiddleware := []echo.MiddlewareFunc{ - // Intercept requests where the model name matches an agent — route directly - // to the agent pool without going through the model config resolution pipeline. - localai.AgentResponsesInterceptor(application), - middleware.UsageMiddleware(application.StatsRecorder(), application.FallbackUser()), - middleware.TraceMiddleware(application), - re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)), - re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenResponsesRequest) }), - setOpenResponsesRequestContext(re), - } + responsesMiddleware := openResponsesMiddleware(re, application) // Main Open Responses endpoint app.POST("/v1/responses", responsesHandler, responsesMiddleware...) @@ -74,6 +67,28 @@ func RegisterOpenResponsesRoutes(app *echo.Echo, app.POST("/responses/:id/cancel", cancelResponseHandler, middleware.TraceMiddleware(application)) } +// openResponsesMiddleware is the middleware chain of POST /v1/responses and +// POST /responses. It is a separate function so tests can drive the real +// chain in front of a stub handler. +func openResponsesMiddleware(re *middleware.RequestExtractor, application *application.Application) []echo.MiddlewareFunc { + return []echo.MiddlewareFunc{ + // Intercept requests where the model name matches an agent — route directly + // to the agent pool without going through the model config resolution pipeline. + localai.AgentResponsesInterceptor(application), + middleware.UsageMiddleware(application.StatsRecorder(), application.FallbackUser()), + middleware.TraceMiddleware(application), + re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)), + re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenResponsesRequest) }), + setOpenResponsesRequestContext(re), + // PII filtering runs once the model config is resolved and merged + // (it gates on the served model's pii block), and before the handler + // turns the input items into prompt messages. Without it, content + // that a model's PII policy blocks or masks on /v1/chat/completions + // would reach the same model unfiltered through this endpoint. + pii.RequestMiddleware(application.PIIRedactor(), application.PIIEvents(), piiadapter.OpenResponses(), application.FallbackUser(), pii.WithNERResolver(application.PIINERResolver()), pii.WithPolicyResolver(application.PIIPolicyResolver())), + } +} + // setOpenResponsesRequestContext sets up the context and cancel function for Open Responses requests func setOpenResponsesRequestContext(re *middleware.RequestExtractor) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { diff --git a/core/http/routes/openresponses_pii_test.go b/core/http/routes/openresponses_pii_test.go new file mode 100644 index 000000000000..ef82dadb5b63 --- /dev/null +++ b/core/http/routes/openresponses_pii_test.go @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +package routes + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/application" + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/http/middleware" + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/pkg/system" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Drives the real POST /v1/responses middleware chain (openResponsesMiddleware) +// in front of a stub handler, with an in-process pattern detector, to prove +// the PII policy that guards /v1/chat/completions also guards this endpoint. +var _ = Describe("Open Responses PII filtering", func() { + var ( + e *echo.Echo + cancel context.CancelFunc + reached bool + received *schema.OpenResponsesRequest + ) + + BeforeEach(func() { + tmp := GinkgoT().TempDir() + modelsDir := filepath.Join(tmp, "models") + Expect(os.MkdirAll(modelsDir, 0o755)).To(Succeed()) + st, err := system.GetSystemState( + system.WithModelPath(modelsDir), + system.WithBackendPath(filepath.Join(tmp, "backends")), + ) + Expect(err).ToNot(HaveOccurred()) + + var ctx context.Context + ctx, cancel = context.WithCancel(context.Background()) + app, err := application.New(config.WithContext(ctx), config.WithSystemState(st)) + Expect(err).ToNot(HaveOccurred()) + + // The pattern tier runs in-process; no backend is loaded. + files := map[string]string{ + "secret-filter.yaml": `name: secret-filter +backend: pattern +pii_detection: + default_action: mask + patterns: + - name: SECRET + match: "sk-test-[A-Za-z0-9]+" + action: block + - name: TOKEN + match: "tok-[A-Za-z0-9]+" + action: mask +`, + "guarded.yaml": `name: guarded +backend: test +pii: + enabled: true + detectors: [secret-filter] +`, + } + for name, body := range files { + p := filepath.Join(modelsDir, name) + Expect(os.WriteFile(p, []byte(body), 0o644)).To(Succeed()) + Expect(app.ModelConfigLoader().ReadModelConfig(p)).To(Succeed()) + } + + re := middleware.NewRequestExtractor(app.ModelConfigLoader(), app.ModelLoader(), app.ApplicationConfig()) + reached, received = false, nil + e = echo.New() + e.POST("/v1/responses", func(c echo.Context) error { + reached = true + received = c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OpenResponsesRequest) + return c.NoContent(http.StatusNoContent) + }, openResponsesMiddleware(re, app)...) + }) + + AfterEach(func() { cancel() }) + + post := func(body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec + } + + It("blocks a secret in a string input with pii_blocked", func() { + rec := post(`{"model":"guarded","input":"my key is sk-test-ABC123"}`) + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(rec.Body.String()).To(ContainSubstring("pii_blocked")) + Expect(rec.Body.String()).ToNot(ContainSubstring("sk-test-ABC123"), "the block response must not echo the secret") + Expect(reached).To(BeFalse(), "a blocked request must not reach the handler") + }) + + It("blocks a secret hidden in a content part, instructions or tool output", func() { + for _, body := range []string{ + `{"model":"guarded","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"key sk-test-ABC123"}]}]}`, + `{"model":"guarded","instructions":"use sk-test-ABC123","input":"hi"}`, + `{"model":"guarded","input":[{"type":"function_call_output","call_id":"c1","output":"sk-test-ABC123"}]}`, + } { + reached = false + rec := post(body) + Expect(rec.Code).To(Equal(http.StatusBadRequest), body) + Expect(rec.Body.String()).To(ContainSubstring("pii_blocked"), body) + Expect(reached).To(BeFalse(), body) + } + }) + + It("masks a maskable value before the handler sees it", func() { + rec := post(`{"model":"guarded","input":[{"type":"message","role":"user","content":"token tok-XYZ789 please"}]}`) + Expect(rec.Code).To(Equal(http.StatusNoContent)) + Expect(reached).To(BeTrue()) + content := received.Input.([]any)[0].(map[string]any)["content"].(string) + Expect(content).ToNot(ContainSubstring("tok-XYZ789")) + Expect(content).To(HavePrefix("token ")) + Expect(content).To(HaveSuffix(" please")) + }) + + It("passes clean input through unchanged", func() { + rec := post(`{"model":"guarded","instructions":"be brief","input":"what is the capital of France?"}`) + Expect(rec.Code).To(Equal(http.StatusNoContent)) + Expect(reached).To(BeTrue()) + Expect(received.Input).To(Equal("what is the capital of France?")) + Expect(received.Instructions).To(Equal("be brief")) + }) +}) diff --git a/core/services/routing/piiadapter/openresponses.go b/core/services/routing/piiadapter/openresponses.go new file mode 100644 index 000000000000..556887630b92 --- /dev/null +++ b/core/services/routing/piiadapter/openresponses.go @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: MIT +package piiadapter + +import ( + "encoding/json" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/routing/pii" + "github.com/mudler/xlog" +) + +// Top-level positions in an Open Responses request. Real input-item +// indices are >= 0, so the negative values cannot collide with them. +const ( + orInstructions = -2 + orInputString = -1 +) + +// encOR packs (item, part) into one ScannedText.Index. item is an index +// into the input item array or one of the orInstructions / orInputString +// sentinels; part=-1 means "the item's whole text field", part>=0 indexes +// into a content-part array. Both are stored offset so the sentinels map +// to non-negative bit fields. +func encOR(item, part int) int { return ((item + 2) << 24) | (part + 1) } +func decOR(p int) (item, part int) { return (p >> 24) - 2, (p & 0xFFFFFF) - 1 } + +// orItemTextField names the field of an input item that carries the text +// the Responses handler forwards to the model, or "" for items whose text +// is not part of the prompt (item_reference: the referenced item was +// scanned when it was first submitted). Scan and Apply both resolve the +// field through here so they can never disagree on where a span lives. +func orItemTextField(item map[string]any) string { + itemType, _ := item["type"].(string) + if itemType == "" { + // Bare {role, content} objects are message items; the handler + // (convertORInputToMessages) treats them the same way. + if _, hasRole := item["role"].(string); hasRole { + itemType = "message" + } + } + switch itemType { + case "message", "reasoning": + return "content" + case "function_call_output": + return "output" + case "function_call": + return "arguments" + } + return "" +} + +// isORTextPart reports whether a content part carries plain text. Image, +// file, audio and video parts are left alone, as in the other adapters. +func isORTextPart(part map[string]any) bool { + t, _ := part["type"].(string) + return t == "input_text" || t == "output_text" +} + +// OpenResponses returns a pii.Adapter for *schema.OpenResponsesRequest +// (POST /v1/responses). It scans every place the handler takes prompt text +// from: Instructions, Input as a plain string, and in an Input item array +// the text of message and reasoning items (string content or input_text / +// output_text parts), function_call arguments and function_call_output +// output. Without it the same content that is blocked on +// /v1/chat/completions could be sent unfiltered through /v1/responses. +// +// A function_call_output whose output is neither a string nor a part array +// is forwarded by the handler as its JSON encoding, so that encoding is +// scanned as one span; a redaction replaces the value with the redacted +// JSON string, which the handler then forwards verbatim. +func OpenResponses() pii.Adapter { + return pii.Adapter{ + Scan: func(parsed any) []pii.ScannedText { + req, ok := parsed.(*schema.OpenResponsesRequest) + if !ok || req == nil { + return nil + } + var out []pii.ScannedText + if req.Instructions != "" { + out = append(out, pii.ScannedText{Index: encOR(orInstructions, -1), Text: req.Instructions}) + } + switch in := req.Input.(type) { + case string: + if in != "" { + out = append(out, pii.ScannedText{Index: encOR(orInputString, -1), Text: in}) + } + case []any: + for i, raw := range in { + item, ok := raw.(map[string]any) + if !ok { + continue + } + field := orItemTextField(item) + if field == "" { + continue + } + switch v := item[field].(type) { + case nil: + // Missing field: nothing reaches the model. + case string: + if v != "" { + out = append(out, pii.ScannedText{Index: encOR(i, -1), Text: v}) + } + case []any: + for j, p := range v { + part, ok := p.(map[string]any) + if !ok || !isORTextPart(part) { + continue + } + if text, ok := part["text"].(string); ok && text != "" { + out = append(out, pii.ScannedText{Index: encOR(i, j), Text: text}) + } + } + default: + if field != "output" { + continue + } + b, err := json.Marshal(v) + if err != nil { + xlog.Warn("pii: cannot encode function_call_output for scanning", "error", err) + continue + } + out = append(out, pii.ScannedText{Index: encOR(i, -1), Text: string(b)}) + } + } + } + return out + }, + Apply: func(parsed any, updates []pii.ScannedText) { + req, ok := parsed.(*schema.OpenResponsesRequest) + if !ok || req == nil { + return + } + for _, u := range updates { + itemIdx, partIdx := decOR(u.Index) + switch itemIdx { + case orInstructions: + req.Instructions = u.Text + continue + case orInputString: + if _, ok := req.Input.(string); ok { + req.Input = u.Text + } + continue + } + items, ok := req.Input.([]any) + if !ok || itemIdx < 0 || itemIdx >= len(items) { + continue + } + item, ok := items[itemIdx].(map[string]any) + if !ok { + continue + } + field := orItemTextField(item) + if field == "" { + continue + } + if partIdx < 0 { + item[field] = u.Text + continue + } + parts, ok := item[field].([]any) + if !ok || partIdx >= len(parts) { + continue + } + if part, ok := parts[partIdx].(map[string]any); ok { + part["text"] = u.Text + } + } + }, + } +} diff --git a/core/services/routing/piiadapter/openresponses_test.go b/core/services/routing/piiadapter/openresponses_test.go new file mode 100644 index 000000000000..3cee290ca772 --- /dev/null +++ b/core/services/routing/piiadapter/openresponses_test.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MIT +package piiadapter + +import ( + "encoding/json" + "strings" + + "github.com/mudler/LocalAI/core/schema" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// parseOR decodes a request body the way the HTTP layer does, so Input +// carries the same []any / map[string]any shapes the adapter sees live. +func parseOR(body string) *schema.OpenResponsesRequest { + GinkgoHelper() + req := &schema.OpenResponsesRequest{} + Expect(json.Unmarshal([]byte(body), req)).To(Succeed()) + return req +} + +// redactSecret mimics a detector: only the secret is rewritten, clean +// text passes through unchanged. +func redactSecret(s string) string { return strings.ReplaceAll(s, "sk-secret", "[REDACTED]") } + +func orItems(req *schema.OpenResponsesRequest) []any { + GinkgoHelper() + items, ok := req.Input.([]any) + Expect(ok).To(BeTrue()) + return items +} + +var _ = Describe("OpenResponses adapter", func() { + a := OpenResponses() + + It("scans and redacts a plain string input", func() { + req := parseOR(`{"model":"m","input":"my key is sk-secret"}`) + got := a.Scan(req) + Expect(got).To(HaveLen(1)) + Expect(got[0].Text).To(Equal("my key is sk-secret")) + applyAll(a, req, redactSecret) + Expect(req.Input).To(Equal("my key is [REDACTED]")) + }) + + It("leaves a clean string input byte-identical", func() { + req := parseOR(`{"model":"m","input":"nothing to hide here"}`) + applyAll(a, req, redactSecret) + Expect(req.Input).To(Equal("nothing to hide here")) + }) + + It("scans and redacts instructions without touching clean input", func() { + req := parseOR(`{"model":"m","instructions":"use sk-secret for the API","input":"hello"}`) + Expect(a.Scan(req)).To(HaveLen(2)) + applyAll(a, req, redactSecret) + Expect(req.Instructions).To(Equal("use [REDACTED] for the API")) + Expect(req.Input).To(Equal("hello")) + }) + + It("redacts message items with string content, including typeless {role, content}", func() { + req := parseOR(`{"model":"m","input":[ + {"type":"message","role":"user","content":"first sk-secret"}, + {"role":"user","content":"second sk-secret"}, + {"type":"message","role":"user","content":"clean text"} + ]}`) + Expect(a.Scan(req)).To(HaveLen(3)) + applyAll(a, req, redactSecret) + items := orItems(req) + Expect(items[0].(map[string]any)["content"]).To(Equal("first [REDACTED]")) + Expect(items[1].(map[string]any)["content"]).To(Equal("second [REDACTED]")) + Expect(items[2].(map[string]any)["content"]).To(Equal("clean text")) + }) + + It("redacts text content parts and leaves non-text parts untouched", func() { + req := parseOR(`{"model":"m","input":[{"type":"message","role":"user","content":[ + {"type":"input_text","text":"look at sk-secret"}, + {"type":"input_image","image_url":"data:image/png;base64,sk-secret"}, + {"type":"input_file","file_data":"sk-secret"}, + {"type":"input_text","text":"clean part"} + ]}]}`) + got := a.Scan(req) + Expect(got).To(HaveLen(2), "only the two input_text parts are scanned") + applyAll(a, req, redactSecret) + parts := orItems(req)[0].(map[string]any)["content"].([]any) + Expect(parts[0].(map[string]any)["text"]).To(Equal("look at [REDACTED]")) + Expect(parts[1].(map[string]any)["image_url"]).To(Equal("data:image/png;base64,sk-secret")) + Expect(parts[2].(map[string]any)["file_data"]).To(Equal("sk-secret")) + Expect(parts[3].(map[string]any)["text"]).To(Equal("clean part")) + }) + + It("redacts tool output given as a string", func() { + req := parseOR(`{"model":"m","input":[ + {"type":"function_call","call_id":"c1","name":"lookup","arguments":"{\"q\":\"sk-secret\"}"}, + {"type":"function_call_output","call_id":"c1","output":"result sk-secret"}, + {"type":"function_call_output","call_id":"c2","output":"clean result"} + ]}`) + Expect(a.Scan(req)).To(HaveLen(3)) + applyAll(a, req, redactSecret) + items := orItems(req) + Expect(items[0].(map[string]any)["arguments"]).To(Equal(`{"q":"[REDACTED]"}`)) + Expect(items[1].(map[string]any)["output"]).To(Equal("result [REDACTED]")) + Expect(items[2].(map[string]any)["output"]).To(Equal("clean result")) + }) + + It("scans structured tool output as its JSON encoding (what the handler forwards)", func() { + req := parseOR(`{"model":"m","input":[{"type":"function_call_output","call_id":"c1","output":{"token":"sk-secret"}}]}`) + got := a.Scan(req) + Expect(got).To(HaveLen(1)) + Expect(got[0].Text).To(Equal(`{"token":"sk-secret"}`)) + applyAll(a, req, redactSecret) + Expect(orItems(req)[0].(map[string]any)["output"]).To(Equal(`{"token":"[REDACTED]"}`)) + }) + + It("leaves structured tool output unchanged when nothing is redacted", func() { + req := parseOR(`{"model":"m","input":[{"type":"function_call_output","call_id":"c1","output":{"ok":true}}]}`) + Expect(a.Scan(req)).To(HaveLen(1)) + // The middleware only applies spans that were actually redacted; + // with no updates the original object must survive as-is. + a.Apply(req, nil) + Expect(orItems(req)[0].(map[string]any)["output"]).To(Equal(map[string]any{"ok": true})) + }) + + It("redacts reasoning content parts and skips item references", func() { + req := parseOR(`{"model":"m","input":[ + {"type":"reasoning","content":[{"type":"output_text","text":"user said sk-secret"}]}, + {"type":"item_reference","id":"msg_sk-secret"} + ]}`) + Expect(a.Scan(req)).To(HaveLen(1)) + applyAll(a, req, redactSecret) + items := orItems(req) + parts := items[0].(map[string]any)["content"].([]any) + Expect(parts[0].(map[string]any)["text"]).To(Equal("user said [REDACTED]")) + Expect(items[1].(map[string]any)["id"]).To(Equal("msg_sk-secret")) + }) + + It("writes each update back only to its own position", func() { + req := parseOR(`{"model":"m","instructions":"inst","input":[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"a"},{"type":"input_text","text":"b"}]}, + {"type":"message","role":"user","content":"c"} + ]}`) + scans := a.Scan(req) + Expect(scans).To(HaveLen(4)) + // Redact only the second part of the first item. + scans[2].Text = "B" + a.Apply(req, scans[2:3]) + Expect(req.Instructions).To(Equal("inst")) + items := orItems(req) + parts := items[0].(map[string]any)["content"].([]any) + Expect(parts[0].(map[string]any)["text"]).To(Equal("a")) + Expect(parts[1].(map[string]any)["text"]).To(Equal("B")) + Expect(items[1].(map[string]any)["content"]).To(Equal("c")) + }) + + It("returns nothing for empty or foreign requests", func() { + Expect(a.Scan(&schema.OpenResponsesRequest{})).To(BeEmpty()) + Expect(a.Scan(&schema.OpenAIRequest{})).To(BeNil()) + Expect(a.Scan(nil)).To(BeNil()) + }) + + It("round-trips the index encoding including the sentinels", func() { + for _, c := range []struct{ item, part int }{ + {orInstructions, -1}, {orInputString, -1}, {0, -1}, {0, 0}, {5, 3}, {100, 70}, + } { + i, p := decOR(encOR(c.item, c.part)) + Expect(i).To(Equal(c.item)) + Expect(p).To(Equal(c.part)) + } + }) +}) diff --git a/docs/content/operations/middleware.md b/docs/content/operations/middleware.md index 541cc81cdfc6..a6e195099fcb 100644 --- a/docs/content/operations/middleware.md +++ b/docs/content/operations/middleware.md @@ -53,11 +53,20 @@ backend default. Filtering runs on every text-accepting endpoint that has an adapter wired: `/v1/chat/completions` and `/v1/messages` (chat), `/v1/completions`, -`/v1/embeddings`, `/v1/edits`, and the Ollama `/api/chat`, `/api/generate` -and `/api/embed` endpoints, plus the [MITM proxy]({{< relref "mitm-proxy.md" >}}) -request body. Image, audio (TTS/STT), video, rerank, and the realtime -WebSocket are not filtered yet (different prompt-PII semantics; realtime is -not HTTP middleware). +`/v1/embeddings`, `/v1/edits`, the Open Responses `POST /v1/responses` +(`instructions`, string `input`, and in input items the message and +reasoning text, `function_call` arguments and `function_call_output` output), +and the Ollama `/api/chat`, `/api/generate` and `/api/embed` endpoints, plus +the [MITM proxy]({{< relref "mitm-proxy.md" >}}) request body. Image, audio +(TTS/STT), video, rerank, and the realtime WebSocket are not filtered yet +(different prompt-PII semantics; realtime is not HTTP middleware). + +The Responses WebSocket mode (`GET /v1/responses`) is not HTTP middleware +either, so it cannot filter: a `response.create` for a model with PII +filtering enabled is refused with the error type `pii_unsupported_transport` +instead of being served unfiltered. Use `POST /v1/responses` for those models. +Requests whose `model` names an agent are handed to the agent pool before +model resolution and are not filtered by the per-model policy. A request's messages are scanned **as one document** (joined in order), so the NER detector keeps conversational context: whether `4421` is a PIN or From bb6dbd4c501f519b380a1844d2c6b8cf5f9ea1aa Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:06:02 +0000 Subject: [PATCH 2/3] fix(pii): scan complete Responses tool output arrays The Responses handler forwards non-string tool output as JSON. Scan that full encoding so string arrays and extra fields cannot bypass PII filtering. Cover array scans, redaction, clean values and route blocking. Document that stored history is resolved after request filtering. Assisted-by: Codex:gpt-6 Signed-off-by: Stefan Walcz --- core/http/routes/openresponses_pii_test.go | 2 ++ .../routing/piiadapter/openresponses.go | 29 ++++++++++--------- .../routing/piiadapter/openresponses_test.go | 26 +++++++++++++++++ docs/content/operations/middleware.md | 7 +++++ 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/core/http/routes/openresponses_pii_test.go b/core/http/routes/openresponses_pii_test.go index ef82dadb5b63..79aa68bfcbf7 100644 --- a/core/http/routes/openresponses_pii_test.go +++ b/core/http/routes/openresponses_pii_test.go @@ -105,6 +105,8 @@ pii: `{"model":"guarded","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"key sk-test-ABC123"}]}]}`, `{"model":"guarded","instructions":"use sk-test-ABC123","input":"hi"}`, `{"model":"guarded","input":[{"type":"function_call_output","call_id":"c1","output":"sk-test-ABC123"}]}`, + `{"model":"guarded","input":[{"type":"function_call_output","call_id":"c1","output":["sk-test-ABC123"]}]}`, + `{"model":"guarded","input":[{"type":"function_call_output","call_id":"c1","output":[{"token":"sk-test-ABC123"}]}]}`, } { reached = false rec := post(body) diff --git a/core/services/routing/piiadapter/openresponses.go b/core/services/routing/piiadapter/openresponses.go index 556887630b92..1df1c03152c7 100644 --- a/core/services/routing/piiadapter/openresponses.go +++ b/core/services/routing/piiadapter/openresponses.go @@ -26,9 +26,9 @@ func decOR(p int) (item, part int) { return (p >> 24) - 2, (p & 0xFFFFFF) - 1 } // orItemTextField names the field of an input item that carries the text // the Responses handler forwards to the model, or "" for items whose text -// is not part of the prompt (item_reference: the referenced item was -// scanned when it was first submitted). Scan and Apply both resolve the -// field through here so they can never disagree on where a span lives. +// is resolved later by the handler (item_reference is not scanned here). +// Scan and Apply both resolve the field through here so they can never +// disagree on where a span lives. func orItemTextField(item map[string]any) string { itemType, _ := item["type"].(string) if itemType == "" { @@ -64,7 +64,7 @@ func isORTextPart(part map[string]any) bool { // output. Without it the same content that is blocked on // /v1/chat/completions could be sent unfiltered through /v1/responses. // -// A function_call_output whose output is neither a string nor a part array +// A function_call_output whose output is not a string (including arrays) // is forwarded by the handler as its JSON encoding, so that encoding is // scanned as one span; a redaction replaces the value with the redacted // JSON string, which the handler then forwards verbatim. @@ -94,6 +94,17 @@ func OpenResponses() pii.Adapter { if field == "" { continue } + // The handler serializes all non-string tool output, including + // arrays. Scan that whole JSON, not just typed text parts. + if _, isString := item[field].(string); field == "output" && !isString { + b, err := json.Marshal(item[field]) + if err != nil { + xlog.Warn("pii: cannot encode function_call_output for scanning", "error", err) + continue + } + out = append(out, pii.ScannedText{Index: encOR(i, -1), Text: string(b)}) + continue + } switch v := item[field].(type) { case nil: // Missing field: nothing reaches the model. @@ -111,16 +122,6 @@ func OpenResponses() pii.Adapter { out = append(out, pii.ScannedText{Index: encOR(i, j), Text: text}) } } - default: - if field != "output" { - continue - } - b, err := json.Marshal(v) - if err != nil { - xlog.Warn("pii: cannot encode function_call_output for scanning", "error", err) - continue - } - out = append(out, pii.ScannedText{Index: encOR(i, -1), Text: string(b)}) } } } diff --git a/core/services/routing/piiadapter/openresponses_test.go b/core/services/routing/piiadapter/openresponses_test.go index 3cee290ca772..889afedf7279 100644 --- a/core/services/routing/piiadapter/openresponses_test.go +++ b/core/services/routing/piiadapter/openresponses_test.go @@ -111,6 +111,32 @@ var _ = Describe("OpenResponses adapter", func() { Expect(orItems(req)[0].(map[string]any)["output"]).To(Equal(`{"token":"[REDACTED]"}`)) }) + DescribeTable("scans and redacts the complete JSON of array tool output", func(output, redacted string) { + req := parseOR(`{"model":"m","input":[{"type":"function_call_output","call_id":"c1","output":` + output + `}]}`) + got := a.Scan(req) + Expect(got).To(HaveLen(1)) + Expect(got[0].Text).To(Equal(output)) + applyAll(a, req, redactSecret) + Expect(orItems(req)[0].(map[string]any)["output"]).To(Equal(redacted)) + }, + Entry("string elements", `["sk-secret"]`, `["[REDACTED]"]`), + Entry("object elements", `[{"token":"sk-secret"}]`, `[{"token":"[REDACTED]"}]`), + Entry("text parts with additional fields", `[{"extra":"sk-secret","text":"clean","type":"input_text"}]`, `[{"extra":"[REDACTED]","text":"clean","type":"input_text"}]`), + ) + + It("preserves clean array tool output when there are no updates", func() { + req := parseOR(`{"model":"m","input":[{"type":"function_call_output","call_id":"c1","output":["clean",{"ok":true}]}]}`) + before, err := json.Marshal(req) + Expect(err).ToNot(HaveOccurred()) + got := a.Scan(req) + Expect(got).To(HaveLen(1)) + Expect(got[0].Text).To(Equal(`["clean",{"ok":true}]`)) + a.Apply(req, nil) + after, err := json.Marshal(req) + Expect(err).ToNot(HaveOccurred()) + Expect(after).To(Equal(before)) + }) + It("leaves structured tool output unchanged when nothing is redacted", func() { req := parseOR(`{"model":"m","input":[{"type":"function_call_output","call_id":"c1","output":{"ok":true}}]}`) Expect(a.Scan(req)).To(HaveLen(1)) diff --git a/docs/content/operations/middleware.md b/docs/content/operations/middleware.md index a6e195099fcb..d0d97919d1d2 100644 --- a/docs/content/operations/middleware.md +++ b/docs/content/operations/middleware.md @@ -68,6 +68,13 @@ instead of being served unfiltered. Use `POST /v1/responses` for those models. Requests whose `model` names an agent are handed to the agent pool before model resolution and are not filtered by the per-model policy. +Stored history loaded through `previous_response_id` or `item_reference` +is resolved after filtering and is not scanned against the current model's +policy. That history can contain unfiltered text from another model or +model-generated output. Tool output supplied directly in the request is +scanned as text, or as its complete JSON encoding for non-string values, +including arrays. + A request's messages are scanned **as one document** (joined in order), so the NER detector keeps conversational context: whether `4421` is a PIN or `jdoe_42` is a username is usually decided by the question asked in the From 407fb2c48a1212fb995b1f6a0d682756eb15bd8a Mon Sep 17 00:00:00 2001 From: Stefan Walcz Date: Sun, 18 Oct 2026 09:46:13 +0200 Subject: [PATCH 3/3] feat(pii): report unscanned media on the Open Responses route Extend block_unscanned_media to /v1/responses: input_image, input_file and input_audio parts inside message items, and the same types as bare input items, are reported as unscanned content. Tool output stays allowed, as it is scanned as JSON text. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Stefan Walcz --- .../routing/piiadapter/openresponses.go | 37 +++++++++++++++++++ .../routing/piiadapter/unscanned_test.go | 22 +++++++++++ docs/content/operations/middleware.md | 5 ++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/core/services/routing/piiadapter/openresponses.go b/core/services/routing/piiadapter/openresponses.go index 1df1c03152c7..a4ee12f34561 100644 --- a/core/services/routing/piiadapter/openresponses.go +++ b/core/services/routing/piiadapter/openresponses.go @@ -51,6 +51,12 @@ func orItemTextField(item map[string]any) string { // isORTextPart reports whether a content part carries plain text. Image, // file, audio and video parts are left alone, as in the other adapters. +// orTextParts are the content part types that carry only text. +var orTextParts = map[string]bool{"input_text": true, "output_text": true, "refusal": true, "summary_text": true} + +// orMediaItems are input item types that are media on their own. +var orMediaItems = map[string]bool{"input_image": true, "input_file": true, "input_audio": true} + func isORTextPart(part map[string]any) bool { t, _ := part["type"].(string) return t == "input_text" || t == "output_text" @@ -127,6 +133,37 @@ func OpenResponses() pii.Adapter { } return out }, + Unscanned: func(parsed any) []string { + req, ok := parsed.(*schema.OpenResponsesRequest) + if !ok || req == nil { + return nil + } + items, ok := req.Input.([]any) + if !ok { + return nil + } + var out []string + seen := map[string]bool{} + for _, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + continue + } + if t, _ := item["type"].(string); orMediaItems[t] && !seen[t] { + // A bare media item outside a message. + seen[t] = true + out = append(out, t) + continue + } + if orItemTextField(item) != "content" { + continue + } + if parts, ok := item["content"].([]any); ok { + out = blockKinds(parts, orTextParts, seen, out) + } + } + return out + }, Apply: func(parsed any, updates []pii.ScannedText) { req, ok := parsed.(*schema.OpenResponsesRequest) if !ok || req == nil { diff --git a/core/services/routing/piiadapter/unscanned_test.go b/core/services/routing/piiadapter/unscanned_test.go index 865f06b249c3..d7cd21da6055 100644 --- a/core/services/routing/piiadapter/unscanned_test.go +++ b/core/services/routing/piiadapter/unscanned_test.go @@ -56,3 +56,25 @@ var _ = Describe("Unscanned content", func() { Expect(OllamaChat().Unscanned(textOnly)).To(BeEmpty()) }) }) + +var _ = Describe("Unscanned content (Open Responses)", func() { + It("lists media parts inside messages and bare media items", func() { + req := &schema.OpenResponsesRequest{Input: []any{ + map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "input_text", "text": "what is this?"}, + map[string]any{"type": "input_image", "image_url": "data:image/png;base64,xyz"}, + }}, + map[string]any{"type": "input_file", "file_data": "JVBERi0="}, + }} + Expect(OpenResponses().Unscanned(req)).To(Equal([]string{"input_image", "input_file"})) + }) + + It("reports nothing for text input, tool output or a plain string", func() { + req := &schema.OpenResponsesRequest{Instructions: "be brief", Input: []any{ + map[string]any{"type": "message", "role": "user", "content": []any{map[string]any{"type": "input_text", "text": "hi"}}}, + map[string]any{"type": "function_call_output", "call_id": "1", "output": []any{map[string]any{"type": "input_image"}}}, + }} + Expect(OpenResponses().Unscanned(req)).To(BeEmpty()) + Expect(OpenResponses().Unscanned(&schema.OpenResponsesRequest{Input: "hi"})).To(BeEmpty()) + }) +}) diff --git a/docs/content/operations/middleware.md b/docs/content/operations/middleware.md index d0d97919d1d2..418677597e1c 100644 --- a/docs/content/operations/middleware.md +++ b/docs/content/operations/middleware.md @@ -293,8 +293,9 @@ they reach the backend unchecked. With the option set, a request that carries any such content is rejected with HTTP 400 (`error.type=pii_blocked`, audit event pattern `__unscanned_media__`) before a detector runs. Use it for egress policies where "cannot be inspected" must mean "not sent", e.g. on -`cloud-proxy` models. It applies to the OpenAI chat, Anthropic messages and -Ollama chat routes, and only while PII is enabled for the model. +`cloud-proxy` models. It applies to the OpenAI chat, Open Responses, +Anthropic messages and Ollama chat routes, and only while PII is enabled for +the model. Multiple detectors **union** their detections; overlapping spans resolve to the strongest action (`block` > `mask` > `allow`). A configured detector