mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 11:46:11 +00:00
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:
parent
bc5da3f4c5
commit
8d33986b5e
4 changed files with 104 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue