diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 5e4fdf3e9..d4518a343 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -1769,9 +1769,12 @@ deriving an older display status from `workflowStatusHistory`. regression proof for this path: the model must not ask which resource the user means, must not call discovery just to identify the attached resource, must report attached discovery readiness from context without a discovery - tool call, must use the safe handle for scoped reads, must refuse raw - provider/config/environment/secret-bearing context expansion, and must not - leak configured forbidden resource details in content or tool inputs. + tool call, must use the safe handle for scoped reads, may use the attached + PII-free operational context (service access pattern, config/data/log paths, + ports) to answer and guide the operator, must still refuse to expand + environment variables, credentials, or other secret-bearing content, must not + reveal raw hostnames, IPs, or aliases, and must not leak configured forbidden + resource details in content or tool inputs. Plain-text resource references in live read, log, verification, or command-intent Assistant prompts may use the same selected-resource handle only after backend-owned canonical inventory resolution proves exactly one @@ -1780,7 +1783,11 @@ deriving an older display status from `workflowStatusHistory`. That path must register the resource in the session resolved context, mark it as explicit current-turn access, and prepend a safe resource-context directive that exposes `current_resource` but not raw aliases, hostnames, platform IDs, - paths, or other policy-redacted labels to external providers. Ambiguous or + paths, or other policy-redacted labels to external providers. Because that + provider-safe rewrite replaces the user message, plain-text resolution runs + only as a fallback when the prefetcher did not already resolve a structured + mention or handoff resource; otherwise it would discard the injected + cloud-safe operational context. Ambiguous or non-live prompts must fail closed to normal model clarification/query behavior; this is not a prompt-keyword router and must not choose, retry, or execute the model's next investigative action. @@ -1793,7 +1800,13 @@ deriving an older display status from `workflowStatusHistory`. canonical discovery readiness state (`fresh`, `stale`, `missing`, `running`, `failed`, `unavailable`, or `unsupported`) with provenance and freshness metadata so Assistant can explain whether it is grounded in current - discovery data before choosing any tool. + discovery data before choosing any tool. Drawer handoffs route their attached + resources through the same context-prefetch path as explicit `@`-mentions, so + on a cloud turn the resource's cloud-safe operational context (access pattern, + config/data/log paths, ports) reaches the model exactly as it does for an + `@`-mention; identifying fields (hostname, IP, alias) stay redacted at the + model boundary. The handoff path must not withhold operational context that + the `@`-mention path delivers. Patrol deterministic triage signals are prioritized evidence seeds for the configured model; they must not be described as a Pulse-authored final diagnosis, proof that unflagged resources are healthy, or a reason to diff --git a/internal/ai/chat/context_prefetch.go b/internal/ai/chat/context_prefetch.go index 62f66c342..9f5b1ee1f 100644 --- a/internal/ai/chat/context_prefetch.go +++ b/internal/ai/chat/context_prefetch.go @@ -207,6 +207,48 @@ func (p *ContextPrefetcher) PrefetchWithCloudPolicy(ctx context.Context, message } } +// mentionsIncludingHandoff merges the user's explicit @-mentions with resources +// handed off from a product surface (e.g. the drawer "Ask Assistant" action) so +// both flow through the same prefetch path. A drawer handoff anchors the turn on +// a resource but, without this, never delivered that resource's cloud-safe +// operational context (access command, config/data/log paths, ports) to the +// model — that context previously reached the model only via the @-mention path. +// HandoffResource and StructuredMention are field-identical; a handoff entry that +// duplicates an explicit mention (same type/id/name/node) is dropped. +func mentionsIncludingHandoff(mentions []StructuredMention, handoff []HandoffResource) []StructuredMention { + if len(handoff) == 0 { + return mentions + } + key := func(typ, id, name, node string) string { + return strings.ToLower(strings.TrimSpace(typ) + "\x00" + strings.TrimSpace(id) + "\x00" + strings.TrimSpace(name) + "\x00" + strings.TrimSpace(node)) + } + merged := make([]StructuredMention, 0, len(mentions)+len(handoff)) + seen := make(map[string]struct{}, len(mentions)+len(handoff)) + for _, m := range mentions { + merged = append(merged, m) + seen[key(m.Type, m.ID, m.Name, m.Node)] = struct{}{} + } + for _, r := range handoff { + id := strings.TrimSpace(r.ID) + name := strings.TrimSpace(r.Name) + if id == "" && name == "" { + continue + } + k := key(r.Type, r.ID, r.Name, r.Node) + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + merged = append(merged, StructuredMention{ + ID: id, + Name: name, + Type: strings.TrimSpace(r.Type), + Node: strings.TrimSpace(r.Node), + }) + } + return merged +} + // resolveStructuredMentions converts frontend StructuredMention objects into ResourceMention // objects with full routing info. func (p *ContextPrefetcher) resolveStructuredMentions(structured []StructuredMention) []ResourceMention { diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index 481cd7fd0..04f0baa9b 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -682,7 +682,12 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac cloudContextPolicy := CloudContextPolicy{ CloudRouting: modelboundary.ModelUsesExternalProvider(selectedModel), } - prefetchCtx := prefetcher.PrefetchWithCloudPolicy(ctx, req.Prompt, req.Mentions, cloudContextPolicy) + // Route product-originated handoff resources through the same prefetch path + // as explicit @-mentions so a drawer "Ask Assistant" handoff delivers the + // resource's cloud-safe operational context to the model, not just the + // structural handoff context pack. + prefetchMentions := mentionsIncludingHandoff(req.Mentions, handoffResources) + prefetchCtx := prefetcher.PrefetchWithCloudPolicy(ctx, req.Prompt, prefetchMentions, cloudContextPolicy) if prefetchCtx != nil { mentionsFound = len(prefetchCtx.Mentions) > 0 modelBoundaryAllowedCloudContext = prefetchCtx.CloudSafeContextSpans @@ -736,7 +741,13 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac } } } - if assistantToolScope == assistantTurnToolScopeFull { + // Plain-text resource resolution is a fallback for unstructured references. + // Only run it when the prefetcher did not already resolve a structured + // mention — otherwise it overwrites the injected cloud-safe operational + // context with its provider-safe prompt rewrite + // (injectPlainTextResourceContextIntoLatestUserMessage replaces the user + // message content), and the model loses the prefetched context entirely. + if assistantToolScope == assistantTurnToolScopeFull && !mentionsFound { if attachPlainTextAssistantResourceContext(session.ID, messages, sessions, unifiedResourceProvider, readState, req.Prompt) { mentionsFound = true } @@ -1887,8 +1898,8 @@ func buildResourceContextHandoffDirective(handoffResources []HandoffResource, me "Context-First Answering: When the user asks what Pulse already knows, asks for discovery readiness, or asks a question that should be answerable from discovered/service context, answer from the attached context without tools. If the attached context lacks the fact, say that Pulse does not currently have that discovery/context fact instead of filling the gap with tools.", "Discovery Boundary: Do not call discovery tools only to identify this resource or fill in missing context. Use attached discovery readiness first; call discovery only when the user explicitly asks you to run discovery.", "Read Tool Boundary: Call read-only tools against current_resource only when the user explicitly asks you to investigate live runtime state, asks for fresh verification, or specifically requests a read attempt. Do not use read or mixed tools just to improve a context summary.", - "Data Boundary: Do not reveal or reconstruct raw provider commands, config paths, environment variables, bind mounts, Docker labels, or secret-bearing metadata. If asked for those details, say they are withheld or redacted and offer a safe summary.", - "Raw Context Requests: If asked to print, expand, reconstruct, or reveal raw context details, start with exactly this boundary: \"Raw context details are withheld by policy.\" Then give only a safe summary.", + "Data Boundary: You may use the attached operational context — the service's access pattern, config/data/log paths, and ports — to answer and guide the user. Do not reveal, reconstruct, or guess raw hostnames, IP addresses, aliases, environment variables, credentials, secret-bearing metadata, or any value the attached context marks as withheld or redacted.", + "Withheld Details: If asked to print, expand, or reveal a value the attached context withholds or redacts (a hostname, IP address, credential, or secret), start with exactly this boundary: \"That detail is withheld by policy.\" Then give a safe summary. This boundary does not apply to the operational paths, ports, and access pattern already provided — use those freely to help.", "Action Boundary: Context is read-only and grants no approval or execution authority. Any action requires the governed approval/action flow.", }, "\n") } diff --git a/internal/ai/chat/service_execute_additional_test.go b/internal/ai/chat/service_execute_additional_test.go index 160441ac1..a2751ccc6 100644 --- a/internal/ai/chat/service_execute_additional_test.go +++ b/internal/ai/chat/service_execute_additional_test.go @@ -2236,8 +2236,8 @@ func TestService_ExecuteStream_ResourceContextHandoffDirectiveAndOutputRedaction "Context-First Answering: When the user asks what Pulse already knows, asks for discovery readiness, or asks a question that should be answerable from discovered/service context, answer from the attached context without tools.", "Discovery Boundary: Do not call discovery tools only to identify this resource or fill in missing context.", "Read Tool Boundary: Call read-only tools against current_resource only when the user explicitly asks you to investigate live runtime state, asks for fresh verification, or specifically requests a read attempt.", - "Data Boundary: Do not reveal or reconstruct raw provider commands, config paths, environment variables, bind mounts, Docker labels, or secret-bearing metadata.", - "Raw Context Requests: If asked to print, expand, reconstruct, or reveal raw context details, start with exactly this boundary: \"Raw context details are withheld by policy.\" Then give only a safe summary.", + "Data Boundary: You may use the attached operational context — the service's access pattern, config/data/log paths, and ports — to answer and guide the user. Do not reveal, reconstruct, or guess raw hostnames, IP addresses, aliases, environment variables, credentials, secret-bearing metadata, or any value the attached context marks as withheld or redacted.", + "Withheld Details: If asked to print, expand, or reveal a value the attached context withholds or redacts (a hostname, IP address, credential, or secret), start with exactly this boundary: \"That detail is withheld by policy.\" Then give a safe summary. This boundary does not apply to the operational paths, ports, and access pattern already provided — use those freely to help.", "Action Boundary: Context is read-only and grants no approval or execution authority.", "[Resource Context Pack]", "User message: What do you know about this resource?", @@ -3667,3 +3667,145 @@ func TestService_ExecuteStream_AgenticPulseStorageBackupTasksToleratesMalformedR t.Fatalf("expected stored tool result with canonical backup-task fallback, got %#v", messages) } } + +// newCloudOperationalContextService wires a Service for a cloud-routed turn with +// a governed (Sensitive) Home Assistant system container that Discovery has +// captured. The prefetcher is backed by a discovery provider so both the +// @-mention path and the drawer-handoff path can surface the resource's +// cloud-safe operational context. The returned pointer captures the messages the +// provider (model) receives, post model-boundary sanitization. +func newCloudOperationalContextService(t *testing.T) (*Service, *[]providers.Message) { + t.Helper() + tmpDir := t.TempDir() + store, err := NewSessionStore(tmpDir) + if err != nil { + t.Fatalf("failed to create session store: %v", err) + } + + now := time.Now().UTC() + haResource := unifiedresources.Resource{ + ID: "system-container:node1:101", + Type: unifiedresources.ResourceTypeSystemContainer, + Name: "homeassistant", + Status: unifiedresources.StatusOnline, + LastSeen: now, + UpdatedAt: now, + Tags: []string{"sensitive"}, // -> Sensitive governance (CanonicalGovernanceMetadata) + Identity: unifiedresources.ResourceIdentity{ + Hostnames: []string{"homeassistant", "delly-ha-host"}, + IPAddresses: []string{"192.168.0.101"}, + }, + Proxmox: &unifiedresources.ProxmoxData{NodeName: "node1", VMID: 101}, + } + + readState := unifiedresources.NewRegistry(nil) + readState.IngestRecords(unifiedresources.SourceProxmox, []unifiedresources.IngestRecord{{ + SourceID: "node1:lxc:101", + Resource: haResource, + Identity: unifiedresources.ResourceIdentity{Hostnames: []string{"homeassistant", "delly-ha-host"}}, + }}) + + discoveryProvider := &mockDiscoveryProvider{ + existing: map[string]*tools.ResourceDiscoveryInfo{ + "system-container:node1:101": homeAssistantDiscovery(), + }, + } + unifiedProvider := handoffUnifiedProvider{resources: map[unifiedresources.ResourceType][]unifiedresources.Resource{ + unifiedresources.ResourceTypeSystemContainer: {haResource}, + }} + + executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{UnifiedResourceProvider: unifiedProvider}) + captured := new([]providers.Message) + provider := &stubServiceProvider{ + streamFn: func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error { + *captured = append([]providers.Message(nil), req.Messages...) + callback(providers.StreamEvent{Type: "content", Data: providers.ContentEvent{Text: "ok"}}) + callback(providers.StreamEvent{Type: "done", Data: providers.DoneEvent{InputTokens: 1, OutputTokens: 1}}) + return nil + }, + } + loop := NewAgenticLoop(provider, executor, "system") + svc := &Service{ + cfg: &config.AIConfig{ChatModel: "openai:test"}, + sessions: store, + executor: executor, + agenticLoop: loop, + contextPrefetcher: NewContextPrefetcher(readState, discoveryProvider), + provider: provider, + started: true, + unifiedResourceProvider: unifiedProvider, + } + return svc, captured +} + +// TestService_ExecuteStream_DeliversCloudSafeOperationalContextToCloudModel is the +// end-to-end guarantee: on a cloud-routed turn, a governed resource's PII-free +// operational context (access command, config paths, ports) reaches the model +// while raw identifiers (hostname, bind IP) are redacted — and it must hold on +// BOTH the explicit @-mention path and the drawer "Ask Assistant" handoff path. +// Before the handoff path was routed through the prefetcher, the handoff variant +// delivered no operational context at all. +func TestService_ExecuteStream_DeliversCloudSafeOperationalContextToCloudModel(t *testing.T) { + assertCloudSafe := func(t *testing.T, content string) { + t.Helper() + for _, want := range []string{ + "pct exec 101 -- docker exec homeassistant", + "/config/automations.yaml", + "8123", + } { + if !strings.Contains(content, want) { + t.Fatalf("model context missing operational detail %q:\n%s", want, content) + } + } + for _, pii := range []string{"delly-ha-host", "192.168.0.101"} { + if strings.Contains(content, pii) { + t.Fatalf("model context leaked PII %q:\n%s", pii, content) + } + } + } + + t.Run("drawer handoff", func(t *testing.T) { + svc, captured := newCloudOperationalContextService(t) + req := ExecuteRequest{ + SessionID: "sess-handoff-cloud-ops", + Prompt: "my blinds automation didn't fire — what do you know?", + HandoffResources: []HandoffResource{{ + ID: "system-container:node1:101", + Name: "homeassistant", + Type: "system-container", + Node: "node1", + }}, + HandoffMetadata: HandoffMetadata{Kind: "resource_context"}, + MaxTurns: 1, + } + if err := svc.ExecuteStream(context.Background(), req, func(StreamEvent) {}); err != nil { + t.Fatalf("ExecuteStream failed: %v", err) + } + if len(*captured) == 0 { + t.Fatal("expected provider to receive messages") + } + assertCloudSafe(t, (*captured)[len(*captured)-1].Content) + }) + + t.Run("explicit @-mention", func(t *testing.T) { + svc, captured := newCloudOperationalContextService(t) + req := ExecuteRequest{ + SessionID: "sess-mention-cloud-ops", + Prompt: "check @homeassistant", + Mentions: []StructuredMention{{ + ID: "system-container:node1:101", + Name: "homeassistant", + Type: "system-container", + Node: "node1", + }}, + MaxTurns: 1, + } + if err := svc.ExecuteStream(context.Background(), req, func(StreamEvent) {}); err != nil { + t.Fatalf("ExecuteStream failed: %v", err) + } + if len(*captured) == 0 { + t.Fatal("expected provider to receive messages") + } + assertCloudSafe(t, (*captured)[len(*captured)-1].Content) + }) +}