mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Honor the cloud_context_privacy dial on non-chat model paths (increment 2 completion)
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).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user