mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user