From 38740f1dc4b97c58ffcb4d09685ed14ab34728cc Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 9 Jun 2026 08:46:40 +0100 Subject: [PATCH] Honor the cloud_context_privacy dial on non-chat model paths (increment 2 completion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While investigating a "bazarr → Unknown Service" discovery report, found that increment 2 only wired the dial into the interactive chat seam. The shared helper (*Service).requestSanitizerForModel — used by discovery analysis, the report and fleet narrators, quick analysis, and the ExecuteAgentic paths — always installed a FULL-redaction sanitizer regardless of the dial. So at the "full" dial those paths silently over-redacted governed resources: e.g. discovery could not identify a governed service even though the operator chose full. (Note: bazarr itself is classified Internal/cloud-summary, so it is NOT redacted — its "Unknown Service" is a discovery service-identification matter, not redaction. This fix addresses the governed-resource case the same gap would break.) Fix: requestSanitizerForModel now resolves the dial from the config snapshot (fail-closed to redacted when absent) and passes RedactLocalOnlyResourcesOnly() at "full", exactly like the chat seam. Local (Ollama) still gets no sanitizer. The local-only hard floor still protects must-not-leave resources at full. Proof: TestRequestSanitizerForModel_HonorsCloudPrivacyDial — at full a Sensitive (local-first) identifier flows while a Restricted (local-only) one stays redacted and the bearer token is always stripped; at redacted both identifiers are redacted; local model gets a nil sanitizer. Contract: ai-runtime universal backstop rule now states the shared helper must honor the dial too (functional parity), not just install the sanitizer. Full internal/ai/... suite green (23 packages). --- .../v6/internal/subsystems/ai-runtime.md | 9 +++ internal/ai/request_sanitizer_dial_test.go | 68 +++++++++++++++++++ internal/ai/service.go | 18 ++++- 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 internal/ai/request_sanitizer_dial_test.go diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index ab387f732..97b063c5c 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -158,6 +158,15 @@ deriving an older display status from `workflowStatusHistory`. request path that skips the sanitizer is a leak and is not permitted. (Static capability probes with no resource content, e.g. the Patrol preflight self-test, are exempt only because their payload is fixed and carries no identifiers.) + The dial-aware option (`RedactLocalOnlyResourcesOnly()` at `full`) is not only + the chat seam's concern: the shared service helper + `(*Service).requestSanitizerForModel` (`internal/ai/service.go`), used by + discovery analysis, the report and fleet narrators, quick analysis, and the + ExecuteAgentic paths, MUST resolve the dial and pass that option at `full` too — + otherwise those non-chat paths silently over-redact governed resources even when + the operator chose `full` (e.g. discovery cannot identify a governed service). + Honoring the dial there is functional parity, and the local-only floor still + protects must-not-leave resources. Redaction-placeholder hygiene: Pulse-authored model-bound directives (the resource-context handoff instructions in `internal/ai/chat/service.go` and `internal/ai/chat/plain_text_resource_context.go`) must NOT inject the literal diff --git a/internal/ai/request_sanitizer_dial_test.go b/internal/ai/request_sanitizer_dial_test.go new file mode 100644 index 000000000..8c28cd0b6 --- /dev/null +++ b/internal/ai/request_sanitizer_dial_test.go @@ -0,0 +1,68 @@ +package ai + +import ( + "strings" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// TestRequestSanitizerForModel_HonorsCloudPrivacyDial proves the shared sanitizer +// helper (used by discovery analysis, report/fleet narrators, quick analysis, and +// ExecuteAgentic) respects the cloud_context_privacy dial — not just the chat seam. +func TestRequestSanitizerForModel_HonorsCloudPrivacyDial(t *testing.T) { + resources := []unifiedresources.Resource{ + { + ID: "vm-1", Name: "finance-vm", Type: unifiedresources.ResourceTypeVM, + Status: unifiedresources.StatusOnline, Tags: []string{"sensitive"}, // -> Sensitive / local-first + Identity: unifiedresources.ResourceIdentity{Hostnames: []string{"finance-vm.lan"}}, + Proxmox: &unifiedresources.ProxmoxData{VMID: 1, NodeName: "n1"}, + }, + { + ID: "agent/vault", Name: "vault", Type: unifiedresources.ResourceTypeAgent, + Status: unifiedresources.StatusOnline, Tags: []string{"secret"}, // -> Restricted / local-only floor + Identity: unifiedresources.ResourceIdentity{Hostnames: []string{"vault.lan"}}, + }, + } + urp := &mockUnifiedResourceProvider{getAllFunc: func() []unifiedresources.Resource { + return append([]unifiedresources.Resource(nil), resources...) + }} + req := providers.ChatRequest{System: "finance-vm.lan and vault.lan. Authorization: Bearer sk-leak-secret-token"} + + sanitizerFor := func(level string) func(providers.ChatRequest) providers.ChatRequest { + s := &Service{cfg: &config.AIConfig{CloudContextPrivacy: level}, unifiedResourceProvider: urp} + return s.requestSanitizerForModel("openai:gpt-4o") + } + + // full: Sensitive (local-first) identifier flows; Restricted (local-only) stays + // redacted as the hard floor; secrets always redacted. + full := sanitizerFor(config.CloudContextPrivacyFull) + if full == nil { + t.Fatal("expected a sanitizer for an external model") + } + out := full(req).System + if !strings.Contains(out, "finance-vm.lan") { + t.Fatalf("full must keep the sensitive identifier, got: %s", out) + } + if strings.Contains(out, "vault.lan") { + t.Fatalf("full must keep the local-only floor (vault.lan redacted), got: %s", out) + } + if strings.Contains(out, "sk-leak-secret-token") { + t.Fatalf("full must still redact the bearer token, got: %s", out) + } + + // redacted: every policied identifier is redacted. + red := sanitizerFor(config.CloudContextPrivacyRedacted) + out = red(req).System + if strings.Contains(out, "finance-vm.lan") || strings.Contains(out, "vault.lan") { + t.Fatalf("redacted must redact all identifiers, got: %s", out) + } + + // local (Ollama): no sanitizer — local is always full. + localSvc := &Service{cfg: &config.AIConfig{CloudContextPrivacy: config.CloudContextPrivacyRedacted}, unifiedResourceProvider: urp} + if localSvc.requestSanitizerForModel("ollama:llama3") != nil { + t.Fatal("expected no sanitizer for a local Ollama model") + } +} diff --git a/internal/ai/service.go b/internal/ai/service.go index 4d8a33749..9c8f38c50 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -1807,8 +1807,24 @@ func (s *Service) QuickAnalysis(ctx context.Context, req QuickAnalysisRequest) ( func (s *Service) requestSanitizerForModel(model string) func(providers.ChatRequest) providers.ChatRequest { s.mu.RLock() urp := s.unifiedResourceProvider + cfg := s.cfg s.mu.RUnlock() - return modelboundary.RequestSanitizerForModel(model, urp) + // Honor the cloud_context_privacy dial on every non-chat model-bound path + // (discovery analysis, report/fleet narrators, quick analysis, ExecuteAgentic), + // not just the interactive chat seam. Fail closed to redacted when no config + // snapshot. At "full" the redaction narrows to the local-only floor so real + // identifiers reach the model for ordinary/Sensitive resources — otherwise the + // dial would silently over-redact (e.g. discovery could not identify a governed + // service even though the operator chose full). + opts := []modelboundary.RequestSanitizerOption{} + cloudPrivacy := config.CloudContextPrivacyRedacted + if cfg != nil { + cloudPrivacy = cfg.GetCloudContextPrivacy() + } + if cloudPrivacy == config.CloudContextPrivacyFull { + opts = append(opts, modelboundary.RedactLocalOnlyResourcesOnly()) + } + return modelboundary.RequestSanitizerForModel(model, urp, opts...) } // GetConfig returns a copy of the current AI config