Deliver cloud-safe operational context to the model on the resource handoff path

The drawer "Ask Assistant" handoff anchored a resource but never delivered its
operational context (access command, config/data/log paths, ports) to the model
— that only reached the model via @-mentions. Route handoff resources through
the same prefetch path so the proactive path matches the @-mention path on cloud
turns; PII (hostname/IP/alias) stays redacted at the model boundary.

- Reconcile the handoff Data Boundary directive: the model may use the PII-free
  operational context to answer and guide the user, while still never revealing
  raw hostnames/IPs/aliases/secrets. Action Boundary unchanged (read-only; any
  mutation goes through the governed approval flow).
- Fix a pre-existing clobber: the plain-text resource resolver overwrote the
  prefetch summary on the @-mention path, dropping the operational context
  before it reached the model. Run it only as a fallback when no structured
  mention resolved.
- Add the first end-to-end test asserting the operational context reaches a
  cloud model on BOTH the handoff and @-mention paths while PII is redacted.
- Update the ai-runtime contract to reflect the reconciled handoff data boundary,
  handoff operational-context parity, and the plain-text fallback ordering.
This commit is contained in:
rcourtman
2026-06-09 10:18:52 +01:00
parent bdb212744c
commit ec91dd43ea
4 changed files with 219 additions and 11 deletions
@@ -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
+42
View File
@@ -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 {
+15 -4
View File
@@ -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")
}
@@ -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)
})
}