Keep Pulse runtime boundaries model-owned

This commit is contained in:
rcourtman 2026-05-17 18:24:13 +01:00
parent 251e8844dc
commit dc0635606d
7 changed files with 179 additions and 18 deletions

View file

@ -148,6 +148,17 @@ runtime cost control, and shared AI transport surfaces.
repeated model attempts must not waive post-write verification or allow a
new state-changing tool before the model has supplied current verification
evidence through an allowed read/resolve path.
Assistant restored-session and recent-session context is also model-bound
context, not an identity-policy bypass: when the referenced unified resource
is governed or redacted, backend context builders must use the resource
policy label or redacted value, suppress exact location/routing metadata
such as `target_host`, and avoid exposing raw provider IDs, aliases, IPs, or
hostnames to the model unless policy allows them.
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
prohibit the model from choosing governed tools for adjacent evidence
relevant to its own findings.
Patrol runtime failures are part of that runtime contract: provider, model,
tool-calling, auth, quota, rate-limit, context-window, and connectivity
failures must be classified in `internal/ai/` before they reach operators,

View file

@ -69,7 +69,8 @@ type SessionFSM struct {
ReadAfterWrite bool `json:"read_after_write"`
// ConsecutiveVerifyBlocks counts consecutive write attempts blocked in VERIFYING.
// After 3 blocks without a successful read, verification is waived to prevent stuck models.
// Repeated model attempts are telemetry only; they must never waive the
// post-write verification requirement.
ConsecutiveVerifyBlocks int `json:"consecutive_verify_blocks,omitempty"`
// LastWriteTool records the last write tool for debugging/telemetry
@ -185,14 +186,6 @@ func (fsm *SessionFSM) CanExecuteTool(kind ToolKind, toolName string) error {
// In VERIFYING, state-changing tools wait for current verification evidence.
if kind == ToolKindWrite {
fsm.ConsecutiveVerifyBlocks++
if fsm.ConsecutiveVerifyBlocks >= 3 {
// Model is stuck - waive verification to prevent infinite blocking.
// Transition back to READING so the write can proceed normally.
fsm.State = StateReading
fsm.ConsecutiveVerifyBlocks = 0
fsm.ReadAfterWrite = false
return nil // Allow this write to proceed
}
return &FSMBlockedError{
State: fsm.State,
ToolName: toolName,

View file

@ -200,6 +200,38 @@ func TestFSM_WriteBlockedInVerifyingWithoutRead(t *testing.T) {
}
}
func TestFSM_VerificationGateDoesNotWaiveAfterRepeatedWriteBlocks(t *testing.T) {
fsm := NewSessionFSM()
fsm.OnToolSuccess(ToolKindResolve, "pulse_query")
fsm.OnToolSuccess(ToolKindWrite, "pulse_control")
for i := 0; i < 5; i++ {
err := fsm.CanExecuteTool(ToolKindWrite, "pulse_docker")
if err == nil {
t.Fatalf("write attempt %d should stay blocked until a verification read", i+1)
}
if fsm.State != StateVerifying {
t.Fatalf("write attempt %d changed state to %s", i+1, fsm.State)
}
if fsm.ReadAfterWrite {
t.Fatalf("write attempt %d should not mark verification as complete", i+1)
}
}
if fsm.ConsecutiveVerifyBlocks != 5 {
t.Fatalf("expected repeated blocks to be counted for telemetry, got %d", fsm.ConsecutiveVerifyBlocks)
}
fsm.OnToolSuccess(ToolKindRead, "pulse_metrics")
if fsm.ConsecutiveVerifyBlocks != 0 {
t.Fatalf("verification read should reset repeated block counter, got %d", fsm.ConsecutiveVerifyBlocks)
}
fsm.CompleteVerification()
if err := fsm.CanExecuteTool(ToolKindWrite, "pulse_docker"); err != nil {
t.Fatalf("write should be allowed after verification evidence, got %v", err)
}
}
func TestFSM_Reset(t *testing.T) {
fsm := NewSessionFSM()

View file

@ -2806,21 +2806,38 @@ func (s *Service) injectRecentSessionContext(sessionID string, messages []Messag
return
}
s.mu.RLock()
resourceProvider := s.unifiedResourceProvider
s.mu.RUnlock()
var lines []string
primaryName := ""
primaryResourceID := ""
primaryTarget := ""
primaryKind := ""
primaryAdapter := ""
primaryAllowsRoutingHint := true
for _, resourceID := range recentIDs {
res, ok := resolvedCtx.GetResourceByID(resourceID)
if !ok || res == nil {
continue
}
policy, aiSafeSummary := recentSessionResourceGovernance(resourceProvider, resourceID, res)
label := res.Name
if label == "" {
label = resourceID
}
if policy != nil || strings.TrimSpace(aiSafeSummary) != "" {
if safeLabel := unifiedresources.ResourcePolicyLabel(label, aiSafeSummary, policy); safeLabel != "" {
label = safeLabel
} else {
label = unifiedresources.ResourcePolicyRedactedValue(label, policy,
unifiedresources.ResourceRedactionAlias,
unifiedresources.ResourceRedactionHostname,
unifiedresources.ResourceRedactionPlatformID,
)
}
}
kind := res.Kind
if kind == "" {
kind = res.ResourceType
@ -2829,6 +2846,13 @@ func (s *Service) injectRecentSessionContext(sessionID string, messages []Messag
if location == "" {
location = res.Scope.HostName
}
if policy != nil && location != "" {
location = unifiedresources.ResourcePolicyRedactedValue(location, policy,
unifiedresources.ResourceRedactionHostname,
unifiedresources.ResourceRedactionAlias,
unifiedresources.ResourceRedactionPlatformID,
)
}
if kind != "" && location != "" {
label = fmt.Sprintf("%s (%s on %s)", label, kind, location)
} else if kind != "" {
@ -2841,6 +2865,7 @@ func (s *Service) injectRecentSessionContext(sessionID string, messages []Messag
primaryTarget = res.TargetHost
primaryKind = kind
primaryAdapter = res.Adapter
primaryAllowsRoutingHint = policy == nil
if primaryName == "" {
primaryName = label
}
@ -2858,10 +2883,12 @@ func (s *Service) injectRecentSessionContext(sessionID string, messages []Messag
if primaryName == "" {
primaryName = primary
}
readHint := readRoutingHintForResolvedResource(primaryKind, primaryAdapter, primaryTarget, primaryName, primaryResourceID)
targetHint := readHint.targetFact()
if targetHint != "" {
lines[0] = lines[0] + "; " + targetHint
if primaryAllowsRoutingHint {
readHint := readRoutingHintForResolvedResource(primaryKind, primaryAdapter, primaryTarget, primaryName, primaryResourceID)
targetHint := readHint.targetFact()
if targetHint != "" {
lines[0] = lines[0] + "; " + targetHint
}
}
log.Debug().
@ -2881,6 +2908,40 @@ func (s *Service) injectRecentSessionContext(sessionID string, messages []Messag
messages[lastIdx].Content = context + "\n\n---\nUser message:\n" + messages[lastIdx].Content
}
func recentSessionResourceGovernance(provider tools.UnifiedResourceProvider, resourceID string, res *ResolvedResource) (*unifiedresources.ResourcePolicy, string) {
if provider == nil || res == nil {
return nil, ""
}
resourceType := res.Kind
if resourceType == "" {
resourceType = res.ResourceType
}
node := res.Node
if node == "" {
node = res.Scope.HostName
}
resource, ok := tools.CanonicalHandoffUnifiedResource(provider, firstNonEmptyRecentString(resourceID, res.ResourceID, res.ProviderUID), res.Name, resourceType, node)
if !ok && res.ResourceID != "" && res.ResourceID != resourceID {
resource, ok = tools.CanonicalHandoffUnifiedResource(provider, res.ResourceID, res.Name, resourceType, node)
}
if !ok && res.ProviderUID != "" {
resource, ok = tools.CanonicalHandoffUnifiedResource(provider, res.ProviderUID, res.Name, resourceType, node)
}
if !ok {
return nil, ""
}
return unifiedresources.CanonicalGovernanceMetadata(&resource)
}
func firstNonEmptyRecentString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func (s *Service) toolsForExecutionMode(autonomousMode bool, patrolMode bool) []providers.Tool {
mcpTools := s.executor.ListTools()
providerTools := ConvertMCPToolsToProvider(mcpTools)

View file

@ -4,6 +4,8 @@ import (
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func TestInjectRecentSessionContext_InjectsNeutralResourceFacts(t *testing.T) {
@ -62,6 +64,62 @@ func TestInjectRecentSessionContext_InjectsNeutralResourceFacts(t *testing.T) {
}
}
func TestInjectRecentSessionContext_RedactsGovernedResourceFacts(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
if err != nil {
t.Fatalf("failed to create session store: %v", err)
}
session, err := store.Create()
if err != nil {
t.Fatalf("failed to create session: %v", err)
}
provider := handoffUnifiedProvider{resources: map[unifiedresources.ResourceType][]unifiedresources.Resource{
unifiedresources.ResourceTypeVM: {{
ID: "vm-100",
Type: unifiedresources.ResourceTypeVM,
Name: "finance-vm",
Status: unifiedresources.StatusWarning,
Tags: []string{"pii"},
Canonical: &unifiedresources.CanonicalIdentity{
DisplayName: "finance-vm",
Hostname: "finance-vm",
PlatformID: "vm-100",
Aliases: []string{"finance-payroll"},
},
Identity: unifiedresources.ResourceIdentity{
Hostnames: []string{"finance-vm"},
IPAddresses: []string{"10.0.0.40"},
},
Proxmox: &unifiedresources.ProxmoxData{NodeName: "pve-secret"},
}},
}}
resolved := store.GetResolvedContext(session.ID)
resolved.AddResourceWithExplicitAccess("finance-vm", &ResolvedResource{
ResourceID: "vm:pve-secret:vm-100",
Name: "finance-vm",
Kind: "vm",
ResourceType: "vm",
Node: "pve-secret",
TargetHost: "pve-secret",
})
messages := []Message{{Role: "user", Content: "what happened?"}}
service := &Service{unifiedResourceProvider: provider}
service.injectRecentSessionContext(session.ID, messages, store)
content := messages[0].Content
if !strings.Contains(content, "virtual machine resource; status warning; local-only context") {
t.Fatalf("expected governed safe summary in recent context, got: %s", content)
}
for _, forbidden := range []string{"finance-vm", "vm-100", "pve-secret", "finance-payroll", "10.0.0.40", "target_host="} {
if strings.Contains(content, forbidden) {
t.Fatalf("recent context leaked governed resource fact %q: %s", forbidden, content)
}
}
}
func TestInjectRecentSessionContext_NoRecentDoesNotModify(t *testing.T) {
store, err := NewSessionStore(t.TempDir())
if err != nil {

View file

@ -968,15 +968,15 @@ Always:
You are in observation mode. Read-only diagnostic evidence is available, but do not modify anything. Report findings with clear recommendations for the user to review and action manually.`
}
const triageSystemPreamble = `You are Pulse Patrol, an autonomous infrastructure analysis agent.
const triageSystemPreamble = `You are Pulse Patrol, a model-owned infrastructure analysis agent.
Deterministic triage has already scanned all resources against thresholds, baselines, backup schedules, disk health, and connectivity. The flagged items are listed in your seed context under "Deterministic Triage Results".
Pulse has assembled deterministic evidence before this turn. The flagged items are listed in your seed context under "Deterministic Triage Results" as prioritized context, not as a final diagnosis and not as proof that unflagged resources are healthy.
Your job is to assess each flagged item. Available evidence sources include historical metrics, logs, backup/replication/RAID details, and resource configuration.
Your job is to assess the provided evidence and decide which items, if any, require attention. Available evidence sources include historical metrics, logs, backup/replication/RAID details, and resource configuration.
After investigation, report confirmed issues via patrol_report_finding and resolve any active findings that are no longer problems.
Do NOT re-scan healthy resources. Triage already verified they are within normal parameters. Focus your turns exclusively on the flagged items.`
Use the triage context to avoid broad routine inventory scans, but do not treat the absence of a flag as conclusive. If surrounding evidence or an active finding makes another resource relevant, choose the governed tools you need and explain the model-owned conclusion.`
func (p *PatrolService) getPatrolSystemPromptForTriage() string {
fullPrompt := p.getPatrolSystemPrompt()

View file

@ -141,9 +141,15 @@ func TestGetPatrolSystemPromptForTriage(t *testing.T) {
}, nil)
prompt := ps.getPatrolSystemPromptForTriage()
if !strings.Contains(prompt, "Deterministic triage has already scanned all resources") {
if !strings.Contains(prompt, "Pulse has assembled deterministic evidence before this turn") {
t.Fatalf("expected triage preamble in prompt, got:\n%s", prompt)
}
if !strings.Contains(prompt, "prioritized context, not as a final diagnosis") {
t.Fatalf("expected triage prompt to preserve model-owned assessment boundary, got:\n%s", prompt)
}
if strings.Contains(prompt, "Triage already verified") || strings.Contains(prompt, "Focus your turns exclusively") {
t.Fatalf("triage prompt must not present deterministic pre-pass as a Pulse-authored judgment boundary, got:\n%s", prompt)
}
if !strings.Contains(prompt, "## Investigation Tools") || !strings.Contains(prompt, "pulse_query") {
t.Fatalf("expected tool descriptions from base prompt, got:\n%s", prompt)
}