Sanitize session-compaction transcript at the model boundary (privacy audit fix)

A systematic audit of all model-bound paths (not just the ones touched reactively)
found a leak: SummarizeSession (internal/ai/chat/session_compaction.go) sent the
PERSISTED chat transcript to the chat model via provider.Chat WITHOUT the
dial-aware model-boundary sanitizer. The transcript is built from persisted
messages — original user prompts and tool outputs — which carry raw resource
identifiers (hostnames/IPs/names) regardless of how the live turns were redacted.
On a cloud chat model at redacted/local_only, that shipped identifiers to the
provider, contradicting the cloud_context_privacy dial. (Secrets were already
redacted at transcript-build time via safety; resource identifiers were not.)

Fix: run the compaction ChatRequest through modelboundary.RequestSanitizerForModel
with the same dial resolution as a normal turn — fail closed to redacted when no
config snapshot, RedactLocalOnlyResourcesOnly() at full, nil (no-op) for local
Ollama. Mirrors the interactive seam exactly.

Audit also checked: report/fleet narrators, quick analysis, ExecuteAgentic(Stream),
discovery analysis, and Patrol all already install the sanitizer (verified). The
Patrol preflight self-test sends a fixed payload with no resource content, so it
needs no sanitizer (verified static, not trusted from the audit summary). agentcontext
output flows through the sanitized agentic loop. So compaction was the one gap.

Contract: ai-runtime gains a UNIVERSAL backstop rule — every model-bound path that
carries infrastructure-derived content must install the dial-aware sanitizer;
session compaction named explicitly; static no-identifier probes exempted. Proof:
TestServiceSummarizeSessionRedactsResourceIdentifiersForCloud (a transcript
hostname is stripped in the captured compaction request at the redacted dial);
existing compaction tests stay green (fix is additive — empty-model path unchanged).
Full internal/ai/... suite green (23 packages).
This commit is contained in:
rcourtman 2026-06-08 22:10:49 +01:00
parent bc5da3f4c5
commit 8d33986b5e
4 changed files with 104 additions and 2 deletions

View file

@ -147,6 +147,17 @@ deriving an older display status from `workflowStatusHistory`.
backstop for any context arriving via tool results, handoff text, or user text.
Local (Ollama) routing is unaffected and always receives full context
(`RequestSanitizerForModel` returns nil for local models).
UNIVERSAL backstop rule: EVERY code path that sends infrastructure-derived
content to an external model MUST install the dial-aware
`modelboundary.RequestSanitizerForModel` (with `RedactLocalOnlyResourcesOnly()`
at the `full` dial) before the provider request — not only the interactive
agentic loop. This explicitly includes session compaction
(`internal/ai/chat/session_compaction.go` `SummarizeSession`), which sends the
PERSISTED transcript (original user prompts and tool outputs carry raw
identifiers regardless of how the live turns were redacted). A new model-bound
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.)
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

View file

@ -26,6 +26,7 @@ import (
type stubServiceProvider struct {
streamFn func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error
chatFn func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error)
}
type sessionAwareStateProvider struct {
@ -322,6 +323,9 @@ func TestService_ListSessionsRefreshesHandoffActionSummary(t *testing.T) {
}
func (s *stubServiceProvider) Chat(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
if s.chatFn != nil {
return s.chatFn(ctx, req)
}
return &providers.ChatResponse{Content: "ok", Model: req.Model}, nil
}

View file

@ -9,8 +9,10 @@ import (
"github.com/google/uuid"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/modelboundary"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/safety"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
const (
@ -77,6 +79,7 @@ func (s *Service) SummarizeSession(ctx context.Context, sessionID string) (map[s
provider := s.provider
cfgSnapshot := s.cfg
costStore := s.costStore
unifiedResourceProvider := s.unifiedResourceProvider
s.mu.RUnlock()
if !started {
@ -127,7 +130,7 @@ func (s *Service) SummarizeSession(ctx context.Context, sessionID string) (map[s
compactCtx, cancel := context.WithTimeout(ctx, sessionCompactionTimeout)
defer cancel()
response, err := provider.Chat(compactCtx, providers.ChatRequest{
compactionRequest := providers.ChatRequest{
System: sessionCompactionSystemPrompt,
ExecutionID: "session-compaction:" + normalizedSessionID,
MaxTokens: 1600,
@ -137,7 +140,27 @@ func (s *Service) SummarizeSession(ctx context.Context, sessionID string) (map[s
Content: "Compact this Pulse Assistant transcript into a handoff summary for the next turn.\n\n" +
transcript,
}},
})
}
// The transcript is built from PERSISTED messages (original user prompts and
// tool outputs), which carry raw resource identifiers regardless of how the
// live turns were redacted. Run it through the same dial-aware model-boundary
// sanitizer as a normal turn so compaction never leaks identifiers to a cloud
// model: redacted/local_only strip them, full keeps the local-only floor, and
// local (Ollama) is unaffected (RequestSanitizerForModel returns nil).
sanitizerOptions := []modelboundary.RequestSanitizerOption{}
cloudPrivacyLevel := config.CloudContextPrivacyRedacted
if cfgSnapshot != nil {
cloudPrivacyLevel = cfgSnapshot.GetCloudContextPrivacy()
}
if cloudPrivacyLevel == config.CloudContextPrivacyFull {
sanitizerOptions = append(sanitizerOptions, modelboundary.RedactLocalOnlyResourcesOnly())
}
if sanitizer := modelboundary.RequestSanitizerForModel(requestModel, unifiedResourceProvider, sanitizerOptions...); sanitizer != nil {
compactionRequest = sanitizer(compactionRequest)
}
response, err := provider.Chat(compactCtx, compactionRequest)
if err != nil {
return nil, fmt.Errorf("compact session: %w", err)
}

View file

@ -6,6 +6,8 @@ import (
"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"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@ -109,6 +111,68 @@ func TestServiceSummarizeSessionUsesProviderAndCompactsTranscript(t *testing.T)
provider.AssertExpectations(t)
}
func TestServiceSummarizeSessionRedactsResourceIdentifiersForCloud(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
require.NoError(t, err)
session, err := store.Create()
require.NoError(t, err)
seedCompactionSession(t, store, session.ID)
// The persisted transcript carries the raw resource hostname the user typed,
// regardless of how the live turn was redacted. Compaction must not ship it
// to a cloud model.
require.NoError(t, store.AddMessage(session.ID, Message{
ID: "vault-turn",
Role: "user",
Content: "check vault.lan health please",
}))
require.NoError(t, store.AddMessage(session.ID, Message{
ID: "vault-answer",
Role: "assistant",
Content: "Acknowledged.",
}))
unifiedProvider := handoffUnifiedProvider{resources: map[unifiedresources.ResourceType][]unifiedresources.Resource{
unifiedresources.ResourceTypeAgent: {{
ID: "agent/vault",
Name: "vault",
Type: unifiedresources.ResourceTypeAgent,
Status: unifiedresources.StatusOnline,
Tags: []string{"secret"}, // -> Restricted -> identifiers redacted
Identity: unifiedresources.ResourceIdentity{Hostnames: []string{"vault.lan"}},
}},
}}
var capturedPrompt string
provider := &MockProvider{}
provider.On("Chat", mock.Anything, mock.MatchedBy(func(req providers.ChatRequest) bool {
if len(req.Messages) == 1 {
capturedPrompt = req.Messages[0].Content
}
return true
})).Return(&providers.ChatResponse{
Content: "Compacted summary.",
Model: "openrouter:test-model",
}, nil).Once()
provider.On("Name").Return("mock-provider").Maybe()
service := &Service{
started: true,
sessions: store,
provider: provider,
cfg: &config.AIConfig{ChatModel: "openrouter:test-model", CloudContextPrivacy: config.CloudContextPrivacyRedacted},
unifiedResourceProvider: unifiedProvider,
}
_, err = service.SummarizeSession(context.Background(), session.ID)
require.NoError(t, err)
require.NotEmpty(t, capturedPrompt)
// The transcript reached the model (proves the turn ran) but the resource
// hostname was redacted at the model boundary by the privacy dial.
require.Contains(t, capturedPrompt, "Prompt turn 1")
require.NotContains(t, capturedPrompt, "vault.lan")
provider.AssertExpectations(t)
}
func messageContents(messages []Message) []string {
contents := make([]string, 0, len(messages))
for _, message := range messages {