mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Project Patrol tools from resource scope
This commit is contained in:
parent
2a15ccbefa
commit
80027e98d6
19 changed files with 428 additions and 33 deletions
|
|
@ -6302,3 +6302,11 @@ detection manifest and model-owned Pulse-state writes. They do not
|
|||
enroll an agent, select an agent identity, enable commands, grant a command
|
||||
session, alter fleet policy, or bypass agent-side preflight and action
|
||||
admission. Agent lifecycle and command authority remain unchanged.
|
||||
|
||||
Exact scoped Watch and investigation requests may also carry a core-resolved
|
||||
canonical resource type through the shared AI handler boundary. The type only
|
||||
selects a smaller read-only provider-tool projection after the Patrol profile
|
||||
has been applied; it cannot select an agent, add an agent-routed tool, establish
|
||||
a command session, or bypass capability, feasibility, policy, approval,
|
||||
dispatch, receipt, and verification gates. Unknown types retain the existing
|
||||
governed Patrol profile rather than guessing at agent authority.
|
||||
|
|
|
|||
|
|
@ -653,6 +653,7 @@ cheap local detection into model-owned diagnosis and governed action.
|
|||
18. `internal/agentcapabilities/markdown.go` shared with `api-contracts`: the Pulse Intelligence manifest Markdown projection, including manifest-owned capability titles, surface-filtered Pulse MCP tool/error inventories, and prompt labels, is both the canonical API/agent documentation projection and the AI runtime onboarding projection for Assistant-compatible external-agent surfaces.
|
||||
19. `internal/agentcapabilities/mcp.go` shared with `api-contracts`: the Pulse Intelligence MCP protocol version, JSON-RPC, method dispatch, method payload, surface-tool-contract-gated initialize operating-instruction and capability advertisement payload, manifest surface-filtered tools/list and tools/call execution bridge, manifest surface-gated resources/list and resources/read bridge, manifest-owned and surface-affordance-gated workflow prompt projection, protocol wire aliases, resource and prompt handler gates, and notification projection collectively define the external-agent adapter wire contract over the shared Pulse Intelligence tool core; MCP initialize, tools/call execution, resource list/read projection, and prompt list/get projection must enter through manifest-owned surface and workflow-prompt contracts so raw capability slices cannot bypass the published external-adapter contract.
|
||||
20. `internal/agentcapabilities/mcp_adapter.go` shared with `api-contracts`: the Pulse MCP adapter setup contract defaults and normalization are both the canonical API manifest setup projection and the AI runtime onboarding contract for Assistant-compatible external-agent surfaces.
|
||||
21. `internal/agentcapabilities/patrol_scope_tools.go` shared with `api-contracts`: the typed Patrol resource-scope evidence mapping is both the AI runtime least-manifest projection contract and the canonical API/agent vocabulary that keeps scoped Watch and investigation tool reductions aligned across the Pulse and enterprise boundary.
|
||||
21. `internal/agentcapabilities/projection.go` shared with `api-contracts`: the agent capability external-tool projection helper, normalized manifest-owned surface tool contract resolution and tools-affordance gating, manifest-owned resource-context route and argument vocabulary, operator-state capability and route vocabulary, finding workflow capability and lifecycle argument vocabulary including resolution and dismissal notes, governed action capability, route, and argument vocabulary, manifest-owned tool title and outputSchema projection, structured Pulse capability _meta, and shared tool behavior hints are both the canonical API manifest projection contract and the AI runtime adapter projection for Pulse Assistant and MCP-facing agent tools, with MCP annotation and metadata wire names confined to adapter-edge aliases.
|
||||
22. `internal/agentcapabilities/provider_tool_artifacts.go` shared with `api-contracts`: the provider tool-call artifact detector and streaming tool-name prefix splitter are both the Assistant stream-sanitization boundary and the shared external-adapter leak guard for provider-native tool-call markup that escaped the structured channel.
|
||||
23. `internal/agentcapabilities/schema.go` shared with `api-contracts`: the agent capability input schema contract is both the canonical API manifest schema envelope and the AI runtime structured tool-schema, governance-aware provider-projection with neutral behavior hints and Pulse governance metadata, offered-tool governance extraction for Assistant prompt policy, manifest-affordance-gated Assistant provider-surface composition, manifest raw-schema to Assistant provider-schema projection for capability tools, legacy native Assistant utility provider aliases and schemas, provider-call normalization, provider-result context projection, Assistant-native interaction provider-tool declaration, and live Assistant execution-normalization contract for Pulse Assistant and MCP-facing agent tools.
|
||||
|
|
@ -7367,10 +7368,21 @@ change to the intent it was designed to protect.
|
|||
|
||||
### Patrol follow-up passes are exact-scope, least-manifest continuations
|
||||
|
||||
Patrol's main Watch pass retains the full governed detection-profile manifest
|
||||
An unscoped Patrol Watch retains the full governed detection-profile manifest
|
||||
so the selected model owns evidence gathering and diagnosis, minus the
|
||||
core-satisfied active-finding read. Bounded continuations do not repeat that
|
||||
broad manifest: the unmatched-signal evaluation pass receives only
|
||||
core-satisfied active-finding read. An explicitly scoped Watch whose exact
|
||||
resource identity resolves to known canonical resource types receives the
|
||||
least evidence manifest for those types plus the detection-owned finding
|
||||
lifecycle. `internal/agentcapabilities/patrol_scope_tools.go` owns that mapping:
|
||||
generic canonical query/read operations remain available for evidence-led
|
||||
cross-resource causality, while unrelated subsystem schemas are removed. The
|
||||
mapping consumes only caller-supplied or core-resolved typed scope, never prompt
|
||||
text. An empty or unknown type keeps the full governed profile instead of
|
||||
guessing and silently blinding Patrol; the allowlist is applied after profile
|
||||
projection and therefore can remove tools but cannot add authority.
|
||||
|
||||
Bounded continuations do not repeat either main-pass manifest: the
|
||||
unmatched-signal evaluation pass receives only
|
||||
`patrol_report_finding` after core has established the complete exact-scope
|
||||
active-finding snapshot; narrow compatibility paths without a complete snapshot
|
||||
receive only `patrol_get_findings` plus `patrol_report_finding`. The
|
||||
|
|
@ -7429,6 +7441,17 @@ finding-lifecycle retries suppressed before canonical invocation are runtime
|
|||
loop-control decisions rather than tool executions and do not create a second
|
||||
tool-start/tool-end pair.
|
||||
|
||||
An investigation carries the finding's trusted canonical `resource_type`
|
||||
through `pkg/aicontracts/orchestrator_deps.go` into the core-owned chat runtime.
|
||||
Core uses the same scoped evidence mapping to project generic query/read tools,
|
||||
the relevant subsystem evidence tool, and only the investigation-owned
|
||||
capability lookup and typed proposal sink. The type is not model-authored and
|
||||
cannot add a tool outside the already-projected investigation profile. Unknown
|
||||
types keep the full safe investigation profile. This keeps focused Patrol
|
||||
usable with smaller local models without replacing model judgment with a
|
||||
diagnostic script or weakening proposal, policy, approval, execution, or
|
||||
verification boundaries.
|
||||
|
||||
### Patrol investigation fallback summaries remain evidence-grounded
|
||||
|
||||
When a provider completes an investigation tool loop but fails to emit the
|
||||
|
|
|
|||
|
|
@ -1415,6 +1415,7 @@ payload shape change when the portal presents compact client rows.
|
|||
44. `internal/agentcapabilities/markdown.go` shared with `ai-runtime`: the Pulse Intelligence manifest Markdown projection, including manifest-owned capability titles, surface-filtered Pulse MCP tool/error inventories, and prompt labels, is both the canonical API/agent documentation projection and the AI runtime onboarding projection for Assistant-compatible external-agent surfaces.
|
||||
45. `internal/agentcapabilities/mcp.go` shared with `ai-runtime`: the Pulse Intelligence MCP protocol version, JSON-RPC, method dispatch, method payload, surface-tool-contract-gated initialize operating-instruction and capability advertisement payload, manifest surface-filtered tools/list and tools/call execution bridge, manifest surface-gated resources/list and resources/read bridge, manifest-owned and surface-affordance-gated workflow prompt projection, protocol wire aliases, resource and prompt handler gates, and notification projection collectively define the external-agent adapter wire contract over the shared Pulse Intelligence tool core; MCP initialize, tools/call execution, resource list/read projection, and prompt list/get projection must enter through manifest-owned surface and workflow-prompt contracts so raw capability slices cannot bypass the published external-adapter contract.
|
||||
46. `internal/agentcapabilities/mcp_adapter.go` shared with `ai-runtime`: the Pulse MCP adapter setup contract defaults and normalization are both the canonical API manifest setup projection and the AI runtime onboarding contract for Assistant-compatible external-agent surfaces.
|
||||
47. `internal/agentcapabilities/patrol_scope_tools.go` shared with `ai-runtime`: the typed Patrol resource-scope evidence mapping is both the AI runtime least-manifest projection contract and the canonical API/agent vocabulary that keeps scoped Watch and investigation tool reductions aligned across the Pulse and enterprise boundary.
|
||||
47. `internal/agentcapabilities/projection.go` shared with `ai-runtime`: the agent capability external-tool projection helper, normalized manifest-owned surface tool contract resolution and tools-affordance gating, manifest-owned resource-context route and argument vocabulary, operator-state capability and route vocabulary, finding workflow capability and lifecycle argument vocabulary including resolution and dismissal notes, governed action capability, route, and argument vocabulary, manifest-owned tool title and outputSchema projection, structured Pulse capability _meta, and shared tool behavior hints are both the canonical API manifest projection contract and the AI runtime adapter projection for Pulse Assistant and MCP-facing agent tools, with MCP annotation and metadata wire names confined to adapter-edge aliases.
|
||||
48. `internal/agentcapabilities/provider_tool_artifacts.go` shared with `ai-runtime`: the provider tool-call artifact detector and streaming tool-name prefix splitter are both the Assistant stream-sanitization boundary and the shared external-adapter leak guard for provider-native tool-call markup that escaped the structured channel.
|
||||
49. `internal/agentcapabilities/schema.go` shared with `ai-runtime`: the agent capability input schema contract is both the canonical API manifest schema envelope and the AI runtime structured tool-schema, governance-aware provider-projection with neutral behavior hints and Pulse governance metadata, offered-tool governance extraction for Assistant prompt policy, manifest-affordance-gated Assistant provider-surface composition, manifest raw-schema to Assistant provider-schema projection for capability tools, legacy native Assistant utility provider aliases and schemas, provider-call normalization, provider-result context projection, Assistant-native interaction provider-tool declaration, and live Assistant execution-normalization contract for Pulse Assistant and MCP-facing agent tools.
|
||||
|
|
@ -4150,6 +4151,18 @@ terminal proposal/final-summary capacity. An absent or zero persisted setting
|
|||
uses the shipped ten-call default; explicit values retain the bounded 5–30
|
||||
contract.
|
||||
|
||||
The same boundary carries the finding's canonical `resource_type` as trusted
|
||||
orchestrator metadata. Core consumes it only through
|
||||
`internal/agentcapabilities/patrol_scope_tools.go` to reduce the already
|
||||
governed investigation provider manifest to generic cross-resource reads, the
|
||||
relevant subsystem evidence surface, and the investigation-owned capability
|
||||
lookup/proposal tools. It is not a client-selectable execution profile, cannot
|
||||
add authority, and is never inferred from prompt text. Empty or unknown types
|
||||
retain the full governed investigation profile so an additive resource kind
|
||||
cannot silently lose evidence access. Scoped Watch uses the same shared typed
|
||||
mapping after exact canonical scope resolution, with detection lifecycle tools
|
||||
composed separately from investigation proposal tools.
|
||||
|
||||
Unified Agent connections now carry agent-authored lifecycle evidence through
|
||||
the shared host model and `/api/connections` contract. The payload may include
|
||||
the applied managed-config fingerprint, self-update state and timestamps, and
|
||||
|
|
@ -9493,3 +9506,13 @@ authority when reached; zero preserves the ordinary uncapped Watch contract.
|
|||
This is an internal execution contract, not a new HTTP field or client-granted
|
||||
capability. Existing Patrol routes, authorization, tenant binding, payloads,
|
||||
and action approval semantics are unchanged.
|
||||
|
||||
Exact scoped Watch runs may now populate that internal allowlist from the
|
||||
core-resolved canonical resource type set. The shared
|
||||
`agentcapabilities.PatrolDetectionToolNamesForResourceTypes` mapping retains
|
||||
generic query/read evidence and only the relevant subsystem and detection
|
||||
lifecycle schemas. Investigations carry their finding `resource_type` over the
|
||||
public Pulse/Enterprise orchestrator dependency contract and use the companion
|
||||
investigation projection. Both paths are reductions after execution-profile
|
||||
projection; neither external clients nor model output can select or widen the
|
||||
surface.
|
||||
|
|
|
|||
|
|
@ -611,6 +611,14 @@
|
|||
"api-contracts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "internal/agentcapabilities/patrol_scope_tools.go",
|
||||
"rationale": "the typed Patrol resource-scope evidence mapping is both the AI runtime least-manifest projection contract and the canonical API/agent vocabulary that keeps scoped Watch and investigation tool reductions aligned across the Pulse and enterprise boundary",
|
||||
"subsystems": [
|
||||
"ai-runtime",
|
||||
"api-contracts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "internal/agentcapabilities/projection.go",
|
||||
"rationale": "the agent capability external-tool projection helper, normalized manifest-owned surface tool contract resolution and tools-affordance gating, manifest-owned resource-context route and argument vocabulary, operator-state capability and route vocabulary, finding workflow capability and lifecycle argument vocabulary including resolution and dismissal notes, governed action capability, route, and argument vocabulary, manifest-owned tool title and outputSchema projection, structured Pulse capability _meta, and shared tool behavior hints are both the canonical API manifest projection contract and the AI runtime adapter projection for Pulse Assistant and MCP-facing agent tools, with MCP annotation and metadata wire names confined to adapter-edge aliases",
|
||||
|
|
|
|||
|
|
@ -5245,3 +5245,11 @@ reductions. They add no storage, backup, snapshot, restore, retention, or
|
|||
recovery tool and cannot turn a detection pass into a mutation path. Storage
|
||||
and recovery state, evidence freshness, persistence, and admission contracts
|
||||
remain unchanged.
|
||||
|
||||
The shared AI handler may also forward the finding's canonical resource type
|
||||
so core can reduce a scoped Watch or investigation to relevant read-only
|
||||
evidence tools. That typed reduction cannot add a storage/recovery capability
|
||||
outside the already-projected Patrol profile and grants no plan, approval,
|
||||
execution, retention, cleanup, restore, or verification authority. An unknown
|
||||
resource type keeps the normal governed profile instead of inferring storage
|
||||
authority from prompt text.
|
||||
|
|
|
|||
90
internal/agentcapabilities/patrol_scope_tools.go
Normal file
90
internal/agentcapabilities/patrol_scope_tools.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package agentcapabilities
|
||||
|
||||
import "strings"
|
||||
|
||||
// PatrolEvidenceToolNamesForResourceTypes returns the smallest useful
|
||||
// read-only evidence surface for an exact, core-resolved Patrol resource
|
||||
// scope. The bool is false when the scope is empty or contains an unknown
|
||||
// resource type; callers must then retain their normal governed profile
|
||||
// rather than guessing from prompt text or accidentally hiding a capability.
|
||||
//
|
||||
// pulse_query and pulse_read are the cross-resource escape hatches: a focused
|
||||
// investigation can still follow canonical topology and inspect a causal
|
||||
// dependency without receiving every subsystem-specific schema up front.
|
||||
func PatrolEvidenceToolNamesForResourceTypes(resourceTypes []string) ([]string, bool) {
|
||||
if len(resourceTypes) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
names := []string{PulseQueryToolName, PulseReadToolName}
|
||||
seen := map[string]bool{
|
||||
PulseQueryToolName: true,
|
||||
PulseReadToolName: true,
|
||||
}
|
||||
add := func(name string) {
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
|
||||
known := 0
|
||||
for _, raw := range resourceTypes {
|
||||
resourceType := strings.ToLower(strings.TrimSpace(raw))
|
||||
if resourceType == "" {
|
||||
continue
|
||||
}
|
||||
known++
|
||||
switch resourceType {
|
||||
case "app-container", "app_container", "docker-container", "docker_container", "docker-host", "docker":
|
||||
add(PulseDockerToolName)
|
||||
case "storage", "storage-pool", "physical-disk", "physical_disk", "pbs", "backup":
|
||||
add(PulseMetricsToolName)
|
||||
add(PulseStorageToolName)
|
||||
case "k8s-cluster", "k8s-node", "k8s-pod", "k8s-deployment", "kubernetes", "kubernetes-cluster":
|
||||
add(PulseKubernetesToolName)
|
||||
case "pmg":
|
||||
add(PulsePMGToolName)
|
||||
case "network-endpoint", "endpoint", "discovery":
|
||||
add(PulseDiscoveryToolName)
|
||||
case "agent", "host", "truenas", "node", "vm", "system-container", "container", "service":
|
||||
add(PulseMetricsToolName)
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
if known == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return names, true
|
||||
}
|
||||
|
||||
// PatrolDetectionToolNamesForResourceTypes composes the scoped evidence
|
||||
// surface with the finding lifecycle owned by the detection profile. It may
|
||||
// only reduce the already-governed profile manifest.
|
||||
func PatrolDetectionToolNamesForResourceTypes(resourceTypes []string) ([]string, bool) {
|
||||
names, ok := PatrolEvidenceToolNamesForResourceTypes(resourceTypes)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return append(names,
|
||||
PatrolGetFindingsToolName,
|
||||
PatrolReportFindingToolName,
|
||||
PatrolAssessFindingToolName,
|
||||
PatrolResolveFindingToolName,
|
||||
), true
|
||||
}
|
||||
|
||||
// PatrolInvestigationToolNamesForResourceTypes composes scoped evidence with
|
||||
// the side-effect-free capability lookup and typed proposal sink. Planning,
|
||||
// approval, execution, and verification remain outside this surface.
|
||||
func PatrolInvestigationToolNamesForResourceTypes(resourceTypes []string) ([]string, bool) {
|
||||
names, ok := PatrolEvidenceToolNamesForResourceTypes(resourceTypes)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return append(names,
|
||||
PatrolActionCapabilitiesToolName,
|
||||
PatrolProposeActionToolName,
|
||||
), true
|
||||
}
|
||||
58
internal/agentcapabilities/patrol_scope_tools_test.go
Normal file
58
internal/agentcapabilities/patrol_scope_tools_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package agentcapabilities
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPatrolScopedToolProjectionUsesTypedLeastManifest(t *testing.T) {
|
||||
dockerEvidence, ok := PatrolEvidenceToolNamesForResourceTypes([]string{"app-container"})
|
||||
if !ok {
|
||||
t.Fatal("app-container scope was not recognized")
|
||||
}
|
||||
if !slices.Equal(dockerEvidence, []string{PulseQueryToolName, PulseReadToolName, PulseDockerToolName}) {
|
||||
t.Fatalf("Docker evidence tools = %v", dockerEvidence)
|
||||
}
|
||||
for _, forbidden := range []string{PulseStorageToolName, PulseKubernetesToolName, PulsePMGToolName, PulseAlertsToolName, PulseKnowledgeToolName, PulseSummarizeToolName} {
|
||||
if slices.Contains(dockerEvidence, forbidden) {
|
||||
t.Fatalf("Docker evidence surface included unrelated tool %q: %v", forbidden, dockerEvidence)
|
||||
}
|
||||
}
|
||||
|
||||
storageInvestigation, ok := PatrolInvestigationToolNamesForResourceTypes([]string{"physical-disk"})
|
||||
if !ok {
|
||||
t.Fatal("physical-disk scope was not recognized")
|
||||
}
|
||||
for _, required := range []string{PulseQueryToolName, PulseReadToolName, PulseMetricsToolName, PulseStorageToolName, PatrolActionCapabilitiesToolName, PatrolProposeActionToolName} {
|
||||
if !slices.Contains(storageInvestigation, required) {
|
||||
t.Fatalf("storage investigation tools missing %q: %v", required, storageInvestigation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolScopedToolProjectionFailsOpenToGovernedProfileForUnknownType(t *testing.T) {
|
||||
if names, ok := PatrolEvidenceToolNamesForResourceTypes(nil); ok || names != nil {
|
||||
t.Fatalf("empty scope = (%v, %v), want no projection", names, ok)
|
||||
}
|
||||
if names, ok := PatrolEvidenceToolNamesForResourceTypes([]string{"future-resource-kind"}); ok || names != nil {
|
||||
t.Fatalf("unknown scope = (%v, %v), want no projection", names, ok)
|
||||
}
|
||||
if names, ok := PatrolEvidenceToolNamesForResourceTypes([]string{"app-container", "future-resource-kind"}); ok || names != nil {
|
||||
t.Fatalf("mixed unknown scope = (%v, %v), want no partial projection", names, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolScopedToolProjectionComposesProfileOwnedTools(t *testing.T) {
|
||||
detection, ok := PatrolDetectionToolNamesForResourceTypes([]string{"network-endpoint"})
|
||||
if !ok {
|
||||
t.Fatal("network-endpoint scope was not recognized")
|
||||
}
|
||||
for _, required := range []string{PulseQueryToolName, PulseReadToolName, PulseDiscoveryToolName, PatrolGetFindingsToolName, PatrolReportFindingToolName, PatrolAssessFindingToolName, PatrolResolveFindingToolName} {
|
||||
if !slices.Contains(detection, required) {
|
||||
t.Fatalf("detection surface missing %q: %v", required, detection)
|
||||
}
|
||||
}
|
||||
if slices.Contains(detection, PatrolProposeActionToolName) {
|
||||
t.Fatalf("detection surface gained investigation proposal authority: %v", detection)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,58 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
func TestRestrictInvestigationProviderToolsForResourceType(t *testing.T) {
|
||||
providerTools := []providers.Tool{
|
||||
{Name: agentcapabilities.PulseQueryToolName},
|
||||
{Name: agentcapabilities.PulseMetricsToolName},
|
||||
{Name: agentcapabilities.PulseStorageToolName},
|
||||
{Name: agentcapabilities.PulseDockerToolName},
|
||||
{Name: agentcapabilities.PulseKubernetesToolName},
|
||||
{Name: agentcapabilities.PulseReadToolName},
|
||||
{Name: agentcapabilities.PulseKnowledgeToolName},
|
||||
{Name: agentcapabilities.PatrolActionCapabilitiesToolName},
|
||||
{Name: agentcapabilities.PatrolProposeActionToolName},
|
||||
}
|
||||
filtered, err := restrictInvestigationProviderToolsForResourceType(providerTools, "app-container")
|
||||
if err != nil {
|
||||
t.Fatalf("restrict Docker investigation: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, tool := range filtered {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
want := []string{
|
||||
agentcapabilities.PulseQueryToolName,
|
||||
agentcapabilities.PulseDockerToolName,
|
||||
agentcapabilities.PulseReadToolName,
|
||||
agentcapabilities.PatrolActionCapabilitiesToolName,
|
||||
agentcapabilities.PatrolProposeActionToolName,
|
||||
}
|
||||
if !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("Docker investigation tools = %v, want %v", names, want)
|
||||
}
|
||||
|
||||
unchanged, err := restrictInvestigationProviderToolsForResourceType(providerTools, "future-resource-kind")
|
||||
if err != nil {
|
||||
t.Fatalf("unknown resource type: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(unchanged, providerTools) {
|
||||
t.Fatalf("unknown resource type changed governed profile: %v", unchanged)
|
||||
}
|
||||
unchanged[0].Name = "mutated"
|
||||
if providerTools[0].Name == "mutated" {
|
||||
t.Fatal("unknown-type fallback aliased the provider manifest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterToolsForPatrol_ConfigFlags(t *testing.T) {
|
||||
svc := &Service{
|
||||
cfg: &config.AIConfig{
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
||||
"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/tools"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
|
@ -25,6 +27,9 @@ type InvestigationRunRequest struct {
|
|||
MaxTurns int
|
||||
MaxEvidenceCalls int
|
||||
ExecutionID string
|
||||
// ResourceType is trusted finding metadata used only for least-manifest
|
||||
// projection. Unknown types retain the normal governed profile.
|
||||
ResourceType string
|
||||
// Identity is the trusted correlation identity injected into any
|
||||
// captured proposal.
|
||||
Identity tools.ProposalIdentity
|
||||
|
|
@ -201,7 +206,12 @@ func (s *Service) ExecuteInvestigationStream(ctx context.Context, req Investigat
|
|||
// Investigation runs are stateless like Patrol detection: only this
|
||||
// run's prompt is loaded; the session is a forensic log.
|
||||
messages := []Message{userMsg}
|
||||
filteredTools := s.toolsForExecutor(executor, false)
|
||||
filteredTools, err := restrictInvestigationProviderToolsForResourceType(
|
||||
s.toolsForExecutor(executor, false), req.ResourceType,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resultMessages, runErr := loop.ExecuteWithTools(ctx, session.ID, messages, filteredTools, callback)
|
||||
for _, msg := range resultMessages {
|
||||
|
|
@ -241,6 +251,18 @@ func (s *Service) ExecuteInvestigationStream(ctx context.Context, req Investigat
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func restrictInvestigationProviderToolsForResourceType(providerTools []providers.Tool, resourceType string) ([]providers.Tool, error) {
|
||||
scopedNames, ok := agentcapabilities.PatrolInvestigationToolNamesForResourceTypes([]string{resourceType})
|
||||
if !ok {
|
||||
return append([]providers.Tool(nil), providerTools...), nil
|
||||
}
|
||||
filtered, err := restrictPatrolProviderTools(providerTools, scopedNames)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to project scoped investigation tools: %w", err)
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// ListInvestigationTools names the tools an investigation run offers,
|
||||
// projected through the same investigation-profile path the run uses.
|
||||
func (s *Service) ListInvestigationTools() []string {
|
||||
|
|
|
|||
|
|
@ -1748,13 +1748,22 @@ Do not investigate, report, resolve, or assess any other Patrol finding. Finding
|
|||
}
|
||||
|
||||
func patrolAllowedToolNamesForScope(scope *PatrolScope) []string {
|
||||
if !isPatrolObjectivePlanningScope(scope) {
|
||||
return nil
|
||||
}
|
||||
if isPatrolObjectivePlanningScope(scope) {
|
||||
return []string{
|
||||
agentcapabilities.PatrolProposeObserverToolName,
|
||||
}
|
||||
}
|
||||
if scope == nil {
|
||||
return nil
|
||||
}
|
||||
resourceTypes := append([]string(nil), scope.ResourceTypes...)
|
||||
resourceTypes = append(resourceTypes, scope.resolvedResourceTypes...)
|
||||
allowed, ok := agentcapabilities.PatrolDetectionToolNamesForResourceTypes(resourceTypes)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
// seedIntelligence holds pre-computed intelligence data used by multiple seed context sections.
|
||||
type seedIntelligence struct {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,35 @@ func TestPatrolObjectivePlanningScopeHasOneMissionAndLeastAuthority(t *testing.T
|
|||
}
|
||||
}
|
||||
|
||||
func TestScopedPatrolUsesCoreResolvedResourceTypeForLeastManifest(t *testing.T) {
|
||||
scope := &PatrolScope{
|
||||
Reason: TriggerReasonManual,
|
||||
ResourceIDs: []string{"container-1"},
|
||||
resolvedResourceTypes: []string{"app-container"},
|
||||
}
|
||||
allowed := patrolAllowedToolNamesForScope(scope)
|
||||
for _, required := range []string{
|
||||
agentcapabilities.PulseQueryToolName,
|
||||
agentcapabilities.PulseReadToolName,
|
||||
agentcapabilities.PulseDockerToolName,
|
||||
agentcapabilities.PatrolReportFindingToolName,
|
||||
} {
|
||||
if !slices.Contains(allowed, required) {
|
||||
t.Fatalf("scoped detection tools missing %q: %v", required, allowed)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
agentcapabilities.PulseStorageToolName,
|
||||
agentcapabilities.PulseKubernetesToolName,
|
||||
agentcapabilities.PulsePMGToolName,
|
||||
agentcapabilities.PatrolProposeActionToolName,
|
||||
} {
|
||||
if slices.Contains(allowed, forbidden) {
|
||||
t.Fatalf("scoped detection tools included %q: %v", forbidden, allowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolObjectivePlanningSeedExcludesGlobalFindingLifecycle(t *testing.T) {
|
||||
patrol := NewPatrolService(nil, nil)
|
||||
patrol.findings.Add(&Finding{
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ func TestResolvePatrolScopeExpandsCanonicalUnifiedIDToSourceResource(t *testing.
|
|||
if len(resolution.EffectiveResourceIDs) == 0 {
|
||||
t.Fatalf("expected effective resource IDs, got %+v", resolution)
|
||||
}
|
||||
if len(resolved.resolvedResourceTypes) != 1 || resolved.resolvedResourceTypes[0] != "vm" {
|
||||
t.Fatalf("resolved resource types = %v, want [vm]", resolved.resolvedResourceTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePatrolScopePreservesCanonicalAvailabilityEndpoint(t *testing.T) {
|
||||
|
|
@ -108,6 +111,9 @@ func TestResolvePatrolScopePreservesCanonicalAvailabilityEndpoint(t *testing.T)
|
|||
if counts.unified != 1 || counts.total() != 1 {
|
||||
t.Fatalf("canonical endpoint counts = %#v, want one unified resource", counts)
|
||||
}
|
||||
if len(resolved.resolvedResourceTypes) != 1 || resolved.resolvedResourceTypes[0] != "network-endpoint" {
|
||||
t.Fatalf("resolved endpoint types = %v, want [network-endpoint]", resolved.resolvedResourceTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePatrolScopeRejectsUnmatchedAndAmbiguousAliases(t *testing.T) {
|
||||
|
|
@ -156,13 +162,19 @@ func TestResolvePatrolScopeResolvesDockerAlertResourceIDs(t *testing.T) {
|
|||
readState := newPatrolRuntimeStateWithProviders(snapshot, registry, unifiedresources.NewUnifiedAIAdapter(registry))
|
||||
|
||||
for _, state := range []patrolRuntimeState{snapshotState, readState} {
|
||||
for _, requested := range []string{containerAlertID, hostAlertID} {
|
||||
resolved, resolution := resolvePatrolScopeState(state, PatrolScope{ResourceIDs: []string{requested}})
|
||||
for _, tc := range []struct {
|
||||
requested string
|
||||
resourceType string
|
||||
}{{containerAlertID, "app-container"}, {hostAlertID, "docker-host"}} {
|
||||
resolved, resolution := resolvePatrolScopeState(state, PatrolScope{ResourceIDs: []string{tc.requested}})
|
||||
if len(resolution.UnmatchedResourceIDs) != 0 || len(resolution.AmbiguousResourceIDs) != 0 {
|
||||
t.Fatalf("alert resource ID %q did not resolve exactly: %+v", requested, resolution)
|
||||
t.Fatalf("alert resource ID %q did not resolve exactly: %+v", tc.requested, resolution)
|
||||
}
|
||||
if len(resolved.ResourceIDs) == 0 {
|
||||
t.Fatalf("alert resource ID %q resolved to an empty scope", requested)
|
||||
t.Fatalf("alert resource ID %q resolved to an empty scope", tc.requested)
|
||||
}
|
||||
if len(resolved.resolvedResourceTypes) != 1 || resolved.resolvedResourceTypes[0] != tc.resourceType {
|
||||
t.Fatalf("alert resource ID %q resolved types = %v, want [%s]", tc.requested, resolved.resolvedResourceTypes, tc.resourceType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const (
|
|||
|
||||
type patrolRuntimeResourceRecord struct {
|
||||
kind patrolRuntimeResourceKind
|
||||
resourceType string
|
||||
ids []string
|
||||
aliases []string
|
||||
}
|
||||
|
|
@ -181,8 +182,11 @@ func (p *PatrolService) patrolRuntimeStateForSnapshot(snapshot models.StateSnaps
|
|||
|
||||
func patrolVisitRuntimeResources(s patrolRuntimeState, visit func(patrolRuntimeResourceRecord) bool) {
|
||||
emittedIDs := make(map[string]struct{})
|
||||
emit := func(kind patrolRuntimeResourceKind, ids []string, aliases ...string) bool {
|
||||
if !visit(patrolRuntimeResourceRecord{kind: kind, ids: ids, aliases: aliases}) {
|
||||
emitTyped := func(kind patrolRuntimeResourceKind, resourceType string, ids []string, aliases ...string) bool {
|
||||
if resourceType == "" {
|
||||
resourceType = canonicalPatrolRuntimeResourceType(kind)
|
||||
}
|
||||
if !visit(patrolRuntimeResourceRecord{kind: kind, resourceType: resourceType, ids: ids, aliases: aliases}) {
|
||||
return false
|
||||
}
|
||||
for _, id := range ids {
|
||||
|
|
@ -192,6 +196,9 @@ func patrolVisitRuntimeResources(s patrolRuntimeState, visit func(patrolRuntimeR
|
|||
}
|
||||
return true
|
||||
}
|
||||
emit := func(kind patrolRuntimeResourceKind, ids []string, aliases ...string) bool {
|
||||
return emitTyped(kind, "", ids, aliases...)
|
||||
}
|
||||
hostResourceKind := func(platform string) patrolRuntimeResourceKind {
|
||||
if strings.EqualFold(strings.TrimSpace(platform), "truenas") {
|
||||
return patrolRuntimeResourceTrueNAS
|
||||
|
|
@ -322,7 +329,7 @@ func patrolVisitRuntimeResources(s patrolRuntimeState, visit func(patrolRuntimeR
|
|||
continue
|
||||
}
|
||||
ids, aliases := patrolUnifiedResourceIdentity(resource)
|
||||
if !emit(patrolRuntimeResourceUnified, ids, aliases...) {
|
||||
if !emitTyped(patrolRuntimeResourceUnified, string(resource.Type), ids, aliases...) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -466,10 +473,33 @@ func canonicalPatrolScopeToken(value string) string {
|
|||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func canonicalPatrolRuntimeResourceType(kind patrolRuntimeResourceKind) string {
|
||||
switch kind {
|
||||
case patrolRuntimeResourceContainer:
|
||||
return "system-container"
|
||||
case patrolRuntimeResourceDockerItem:
|
||||
return "app-container"
|
||||
case patrolRuntimeResourcePhysicalDisk:
|
||||
return "physical-disk"
|
||||
case patrolRuntimeResourceHost:
|
||||
return "agent"
|
||||
case patrolRuntimeResourceTrueNAS:
|
||||
return "truenas"
|
||||
case patrolRuntimeResourceUnified:
|
||||
return ""
|
||||
default:
|
||||
return string(kind)
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePatrolScopeState expands canonical unified IDs, source IDs, and
|
||||
// unique known aliases onto the complete identity set for the matching Patrol
|
||||
// runtime record. It never fuzzy-matches and refuses ambiguous aliases.
|
||||
func resolvePatrolScopeState(state patrolRuntimeState, scope PatrolScope) (PatrolScope, PatrolScopeResolution) {
|
||||
// Recompute private type evidence from the current runtime snapshot on every
|
||||
// resolution pass; callers may synchronously pre-resolve and the accepted run
|
||||
// resolves again to close collection races.
|
||||
scope.resolvedResourceTypes = nil
|
||||
resolution := PatrolScopeResolution{}
|
||||
for _, requested := range scope.ResourceIDs {
|
||||
if requested = strings.TrimSpace(requested); requested != "" {
|
||||
|
|
@ -485,6 +515,7 @@ func resolvePatrolScopeState(state patrolRuntimeState, scope PatrolScope) (Patro
|
|||
type identityRecord struct {
|
||||
ids []string
|
||||
aliases []string
|
||||
resourceType string
|
||||
idTokens map[string]bool
|
||||
aliasTokens map[string]bool
|
||||
}
|
||||
|
|
@ -492,6 +523,7 @@ func resolvePatrolScopeState(state patrolRuntimeState, scope PatrolScope) (Patro
|
|||
patrolVisitRuntimeResources(state, func(record patrolRuntimeResourceRecord) bool {
|
||||
candidate := identityRecord{
|
||||
ids: append([]string(nil), record.ids...), aliases: append([]string(nil), record.aliases...),
|
||||
resourceType: record.resourceType,
|
||||
idTokens: make(map[string]bool), aliasTokens: make(map[string]bool),
|
||||
}
|
||||
for _, value := range record.ids {
|
||||
|
|
@ -513,6 +545,7 @@ func resolvePatrolScopeState(state patrolRuntimeState, scope PatrolScope) (Patro
|
|||
expanded := make([]string, 0, len(scope.ResourceIDs))
|
||||
seenExpanded := make(map[string]bool)
|
||||
seenResolved := make(map[string]bool)
|
||||
seenResolvedTypes := make(map[string]bool)
|
||||
addExpanded := func(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" && !seenExpanded[value] {
|
||||
|
|
@ -527,6 +560,13 @@ func resolvePatrolScopeState(state patrolRuntimeState, scope PatrolScope) (Patro
|
|||
resolution.ResolvedResourceIDs = append(resolution.ResolvedResourceIDs, value)
|
||||
}
|
||||
}
|
||||
addResolvedType := func(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" && !seenResolvedTypes[value] {
|
||||
seenResolvedTypes[value] = true
|
||||
scope.resolvedResourceTypes = append(scope.resolvedResourceTypes, value)
|
||||
}
|
||||
}
|
||||
for _, requested := range resolution.RequestedResourceIDs {
|
||||
token := canonicalPatrolScopeToken(requested)
|
||||
matches := make([]identityRecord, 0, 1)
|
||||
|
|
@ -552,6 +592,7 @@ func resolvePatrolScopeState(state patrolRuntimeState, scope PatrolScope) (Patro
|
|||
addExpanded(id)
|
||||
addResolved(id)
|
||||
}
|
||||
addResolvedType(matches[0].resourceType)
|
||||
default:
|
||||
resolution.AmbiguousResourceIDs = append(resolution.AmbiguousResourceIDs, requested)
|
||||
}
|
||||
|
|
@ -563,6 +604,7 @@ func resolvePatrolScopeState(state patrolRuntimeState, scope PatrolScope) (Patro
|
|||
resolution.EffectiveResourceIDs = patrolRuntimeSortedResourceIDs(filtered)
|
||||
}
|
||||
sort.Strings(resolution.ResolvedResourceIDs)
|
||||
sort.Strings(scope.resolvedResourceTypes)
|
||||
sort.Strings(resolution.EffectiveResourceIDs)
|
||||
sort.Strings(resolution.UnmatchedResourceIDs)
|
||||
sort.Strings(resolution.AmbiguousResourceIDs)
|
||||
|
|
|
|||
|
|
@ -104,6 +104,10 @@ type PatrolScope struct {
|
|||
// prevents an authoritative ID from subsequently matching a different
|
||||
// resource whose display alias happens to contain the same string.
|
||||
resolvedIdentityOnly bool
|
||||
// resolvedResourceTypes is the core-derived type set for exact resource-ID
|
||||
// scopes. It is used only to reduce the provider tool manifest; it never
|
||||
// broadens collection scope or mutation authority.
|
||||
resolvedResourceTypes []string
|
||||
}
|
||||
|
||||
// PatrolScopeResolution records how caller-supplied identities map onto the
|
||||
|
|
|
|||
|
|
@ -2199,6 +2199,7 @@ func (a *orchestratorChatAdapter) ExecuteInvestigationStream(ctx context.Context
|
|||
MaxTurns: req.MaxTurns,
|
||||
MaxEvidenceCalls: req.MaxEvidenceCalls,
|
||||
ExecutionID: req.ExecutionID,
|
||||
ResourceType: req.ResourceType,
|
||||
Identity: tools.ProposalIdentity{
|
||||
ProposalID: req.ProposalID,
|
||||
FindingID: req.FindingID,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ func TestAIHandlersUseSafeRemediationCommercialCopy(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestOrchestratorChatAdapterCarriesTypedInvestigationScope(t *testing.T) {
|
||||
source, err := os.ReadFile("ai_handlers.go")
|
||||
require.NoError(t, err)
|
||||
text := string(source)
|
||||
require.Contains(t, text, "ResourceType: req.ResourceType")
|
||||
require.NotContains(t, text, "ResourceType: req.Prompt")
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_AnthropicOAuthSetupFailsClosed(t *testing.T) {
|
||||
handler := &AISettingsHandler{}
|
||||
|
||||
|
|
|
|||
|
|
@ -1074,6 +1074,7 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
|
|||
"catalog: h.proposalCatalogFor(orgID)",
|
||||
"func (a *orchestratorChatAdapter) ExecuteInvestigationStream(",
|
||||
"MaxEvidenceCalls: req.MaxEvidenceCalls",
|
||||
"ResourceType: req.ResourceType",
|
||||
"ModelTurns: runResult.ModelTurns",
|
||||
"EvidenceCalls: runResult.EvidenceCalls",
|
||||
} {
|
||||
|
|
|
|||
|
|
@ -418,7 +418,7 @@ func TestInvestigationResultCarriesStructuredProposal(t *testing.T) {
|
|||
|
||||
func TestInvestigationBudgetSeparatesEvidenceCallsFromModelTurns(t *testing.T) {
|
||||
requestType := reflect.TypeOf(OrchestratorInvestigationRequest{})
|
||||
for _, name := range []string{"MaxTurns", "MaxEvidenceCalls"} {
|
||||
for _, name := range []string{"MaxTurns", "MaxEvidenceCalls", "ResourceType"} {
|
||||
if _, ok := requestType.FieldByName(name); !ok {
|
||||
t.Fatalf("investigation request missing %s", name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ type OrchestratorInvestigationRequest struct {
|
|||
ProposalID string `json:"proposal_id"`
|
||||
FindingID string `json:"finding_id"`
|
||||
InvestigationID string `json:"investigation_id"`
|
||||
// ResourceType is trusted finding metadata used by core only to reduce the
|
||||
// investigation tool manifest. It cannot add tools or mutation authority.
|
||||
ResourceType string `json:"resource_type,omitempty"`
|
||||
EvidenceIDs []string `json:"evidence_ids,omitempty"`
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue