Project Patrol tools from resource scope

This commit is contained in:
rcourtman 2026-08-15 12:38:41 +01:00
parent 2a15ccbefa
commit 80027e98d6
19 changed files with 428 additions and 33 deletions

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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",

View file

@ -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.

View 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
}

View 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)
}
}

View file

@ -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{

View file

@ -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 {

View file

@ -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 {

View file

@ -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{

View file

@ -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)
}
}
}

View file

@ -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)

View file

@ -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

View file

@ -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,

View file

@ -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{}

View file

@ -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",
} {

View file

@ -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)
}

View file

@ -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"`
}