From 313648b2f1c050c7bd8143022dabc33ab447ce5f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 3 Jul 2026 18:01:05 +0100 Subject: [PATCH] feat(mcp): add additive fleet-context filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_fleet_context returned the whole registry with no way to narrow it, so a large fleet produced a payload that exceeded the agent harness's 50KB cap and forced agents to receive or page through healthy resources that are pure noise from a triage standpoint. Add optional additive query-param filters — hasFindings, severity, technology, resourceType — that compose by intersection. All optional (omitting every filter returns the full fleet, backward compatible); unknown/unmatched values return 200 with resources: [] (a valid triage answer, not an error). The hasFindings=true filter is the headline triage case: show me only what needs attention. The MCP adapter layer had no GET query-param transport — ProjectCapabilityCall short-circuited GET after path substitution and dropped all other args. Close that gap generically: ProjectedCall gains a Query url.Values field populated from leftover non-path public args for GET/DELETE, and BuildCapabilityHTTPRequest encodes it onto the request URL. Benefits any future GET capability with filter args, not just fleet-context. The resources/list adapter calls fleet-context with empty args and continues to receive the unfiltered fleet. The manifest now declares the filters via inputSchema so they are discoverable through the capability surface the same way add_node's params are. - ProjectedCall.Query + GET query-param forwarding (projection.go, http.go) - fleetContextFilter parsing/matching in HandleFleetContext - fleetContextInputSchema + InputSchema on the capability - Regenerated cmd/pulse-mcp/README.md via generate-pulse-intelligence-docs - Contract updates: api-contracts, ai-runtime, agent-lifecycle, storage-recovery - Tests: adapter query-forwarding unit tests, 7 filter handler tests (hasFindings/severity/technology/resourceType/no-filter/unknown/compose), contract pinning inputSchema + end-to-end query forwarding --- cmd/pulse-mcp/README.md | 2 +- .../v6/internal/subsystems/agent-lifecycle.md | 6 +- .../v6/internal/subsystems/ai-runtime.md | 8 + .../v6/internal/subsystems/api-contracts.md | 15 ++ .../internal/subsystems/storage-recovery.md | 6 +- internal/agentcapabilities/http.go | 6 + internal/agentcapabilities/http_test.go | 67 +++++++ internal/agentcapabilities/manifest.go | 28 ++- internal/agentcapabilities/projection.go | 25 +++ internal/api/agent_resource_context.go | 93 ++++++++- internal/api/agent_resource_context_test.go | 183 ++++++++++++++++++ internal/api/contract_test.go | 51 +++++ 12 files changed, 485 insertions(+), 5 deletions(-) diff --git a/cmd/pulse-mcp/README.md b/cmd/pulse-mcp/README.md index 9ac0381f7..d94c5a03a 100644 --- a/cmd/pulse-mcp/README.md +++ b/cmd/pulse-mcp/README.md @@ -229,7 +229,7 @@ the live set. - `get_resource_context` (Get resource context, `GET /api/agent/resource-context/{resourceId}`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the situated picture of a resource — identity, operator-set state with maintenance-window-active flag, active findings, pending approvals scoped to this resource, recent actions including refused dispatches. Command fields are redacted for monitoring-read API tokens unless the token also has ai:execute. - `list_resource_capabilities` (List resource capabilities, `GET /api/agent/resource-capabilities/{resourceId}`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the structured governed capabilities a resource advertises (name, type, approval level, platform, and full parameter schemas). Companion to get_resource_context, which renders capabilities as count-limited prose; this is the structured surface an agent reads before calling plan_action so it can populate capabilityName and params without guessing. A resource with no advertised capabilities returns an empty array. -- `get_fleet_context` (Get fleet context, `GET /api/agent/fleet-context`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth. +- `get_fleet_context` (Get fleet context, `GET /api/agent/fleet-context`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth. Optional additive filters (hasFindings, severity, technology, resourceType) narrow the result to a relevant subset so agents triaging a large fleet do not receive or page through healthy resources. - `get_patrol_control_status` (Get Patrol work status, `GET /api/agent/patrol-control/status`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the current content-safe Patrol work status: Patrol issue evidence, pending approvals, governed decisions/actions, verified outcomes, Patrol control outcome evidence, compatibility aliases, and optional MCP readiness. The payload is count-only and deliberately omits finding ids, action ids, prompts, commands, resource names, and output. **Operator state (per-resource intent):** diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 173c1f554..e1dde63f6 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1670,7 +1670,11 @@ walks the registry once and reuses the in-memory findings index, one bounded approval-store scan grouped by canonical resource id, and a per-resource operator-state SQLite point lookup. Agents pick "where do I focus?" from the fleet view and then drill into the -per-resource bundle for depth. +per-resource bundle for depth. Optional additive filter query params +(hasFindings, severity, technology, resourceType) narrow the sweep to +a relevant subset; they introduce no new lifecycle state, operator +intent, or persistence — they are a read-only projection over the +same in-memory registry walk. `/api/agent/capabilities` is registered in the router's `publicPaths` list so the global auth middleware does not gate diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 1624edcad..3f71e1edc 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -806,6 +806,14 @@ projections: `resources/list` must call the canonical `get_fleet_context` capability, `resources/read` must call `get_resource_context`, and the `pulse://resource/` URI shape, resource content MIME type, and resource read parameter validation live in `internal/agentcapabilities` rather +than in `cmd/pulse-mcp` or an MCP-only inventory registry. The +`get_fleet_context` capability accepts optional additive filter arguments +(hasFindings, severity, technology, resourceType) that an agent may pass +through `tools/call` to narrow the fleet to a relevant subset; the projection +layer forwards non-path GET arguments as URL query parameters so the manifest +contract, not a per-capability adapter, owns query-string transport. The +`resources/list` adapter calls fleet-context with no filters and continues to +receive the full fleet. than in `cmd/pulse-mcp` or an MCP-only inventory registry. They must enter through `ManifestSurfaceResourceCapabilities`, `ListMCPManifestSurfaceResourcesHTTP`, and diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index af6cd99e8..46e3aaac1 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3693,6 +3693,21 @@ approval counts must come from one org-scoped, resource-keyed scan of the bounded pending-approval list, not N calls to the per-resource approval summary helper. Agents pick a focus from the fleet view, then drill into the per-resource bundle for depth. +`/api/agent/fleet-context` accepts optional additive filter query +parameters — `hasFindings=true` (only resources with at least one +active finding), `severity=critical|warning|info` (at least one +finding at that severity), `technology=`, and +`resourceType=` (case-insensitive exact match). All are +optional and compose by intersection; omitting every filter returns +the full fleet (backward compatible). Unknown or unmatched values +return 200 with `resources: []` rather than an error, because "no +resource matched this filter" is a valid triage answer. The filters +narrow what would otherwise be a full-registry sweep, keeping the +payload bounded for agents triaging a large fleet without paging +through healthy resources; the manifest-owned `inputSchema` declares +them so agents discover the filters through the capability surface, +and non-path GET arguments are forwarded as query parameters by the +shared projection layer rather than a per-capability adapter. `/api/agent/resource-capabilities/{id}` is the agent-consumable structured capability surface for a single resource: the governed diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index c27e8ecc0..f6dac90ca 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1730,7 +1730,11 @@ artifact or storage/recovery freshness signal. The companion `/api/agent/resource-capabilities/{id}` endpoint is the same kind of read-only projection: it returns the in-memory `Resource.Capabilities` slice the registry already assembles, so -no new persistence or recovery artifact is introduced. The recovery posture +no new persistence or recovery artifact is introduced. The +optional additive filter query params on fleet-context +(hasFindings, severity, technology, resourceType) likewise +introduce no new persistence: they only narrow the in-memory +registry walk, so the recovery posture is unchanged. The recovery posture is identical to the per-resource bundle: when the unified-resources store and the approval store rehydrate on startup, the fleet view recovers the same situated picture without any fleet-specific diff --git a/internal/agentcapabilities/http.go b/internal/agentcapabilities/http.go index 7458d4d4a..c3094394d 100644 --- a/internal/agentcapabilities/http.go +++ b/internal/agentcapabilities/http.go @@ -152,6 +152,12 @@ func BuildCapabilityHTTPRequest(ctx context.Context, baseURL, token string, cap if err != nil { return nil, ProjectedCall{}, err } + // Attach forwarded query parameters (GET/DELETE filter arguments). The + // projection layer owns which arguments become query params; here we only + // encode them onto the request URL. + if len(projected.Query) > 0 { + req.URL.RawQuery = projected.Query.Encode() + } return req, projected, nil } diff --git a/internal/agentcapabilities/http_test.go b/internal/agentcapabilities/http_test.go index 25f6f0c40..2667699df 100644 --- a/internal/agentcapabilities/http_test.go +++ b/internal/agentcapabilities/http_test.go @@ -80,6 +80,73 @@ func TestBuildCapabilityHTTPRequestProjectsPathBodyAndAuth(t *testing.T) { } } +func TestBuildCapabilityHTTPRequestForwardsGETFiltersAsQuery(t *testing.T) { + // A GET capability with non-path arguments must forward them as URL query + // parameters (not a body, not dropped) so filter-style tools like + // get_fleet_context can receive agent-supplied filters through the MCP + // tools/call path. + cap := Capability{ + Name: FleetContextCapabilityName, + Method: http.MethodGet, + Path: FleetContextCapabilityPath, + } + req, projected, err := BuildCapabilityHTTPRequest(context.Background(), "http://pulse.local/", "token", cap, map[string]any{ + "hasFindings": "true", + "technology": "docker", + }) + if err != nil { + t.Fatalf("BuildCapabilityHTTPRequest: %v", err) + } + if req.Method != http.MethodGet { + t.Fatalf("method = %q, want GET", req.Method) + } + if projected.HasBody { + t.Fatalf("GET projection must not produce a body; projected = %+v", projected) + } + if projected.Query == nil { + t.Fatal("projected.Query must be populated for a GET with filter args") + } + if got := projected.Query.Get("hasFindings"); got != "true" { + t.Errorf("query hasFindings = %q, want true", got) + } + if got := projected.Query.Get("technology"); got != "docker" { + t.Errorf("query technology = %q, want docker", got) + } + // The query must land on the request URL encoded. + if req.URL.RawQuery == "" { + t.Fatalf("RawQuery empty; url = %q", req.URL.String()) + } + gotFindings := req.URL.Query().Get("hasFindings") + gotTech := req.URL.Query().Get("technology") + if gotFindings != "true" || gotTech != "docker" { + t.Errorf("url query = hasFindings=%q technology=%q", gotFindings, gotTech) + } + if req.Body != nil { + t.Fatalf("GET request must not carry a body; got %+v", req.Body) + } +} + +func TestBuildCapabilityHTTPRequestGETWithoutArgsHasNoQuery(t *testing.T) { + // A GET with no filter arguments must not synthesize a query string — + // backward compatibility for the resources/list adapter and any caller + // that wants the unfiltered result. + cap := Capability{ + Name: FleetContextCapabilityName, + Method: http.MethodGet, + Path: FleetContextCapabilityPath, + } + req, projected, err := BuildCapabilityHTTPRequest(context.Background(), "http://pulse.local/", "token", cap, map[string]any{}) + if err != nil { + t.Fatalf("BuildCapabilityHTTPRequest: %v", err) + } + if projected.Query != nil { + t.Fatalf("projected.Query = %+v, want nil for argless GET", projected.Query) + } + if req.URL.RawQuery != "" { + t.Fatalf("RawQuery = %q, want empty", req.URL.RawQuery) + } +} + func TestCallCapabilityHTTPExecutesManifestProjection(t *testing.T) { var got struct { Method string diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index 23a159ae3..617f67b5a 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -30,6 +30,31 @@ func fleetContextOutputSchema() json.RawMessage { }) } +// fleetContextInputSchema declares the optional additive filters for +// get_fleet_context. None are required; omitting all returns the full fleet +// (backward compatible). Forwarded as URL query parameters for the GET. +func fleetContextInputSchema() json.RawMessage { + return agentObjectInputSchema(nil, map[string]any{ + "hasFindings": map[string]any{ + "type": "boolean", + "description": "When true, return only resources with at least one active finding. Omit to include healthy resources.", + }, + "severity": map[string]any{ + "type": "string", + "enum": []string{"critical", "warning", "info"}, + "description": "Return only resources with at least one finding at this severity. Composes with hasFindings and the dimension filters.", + }, + "technology": map[string]any{ + "type": "string", + "description": "Return only resources whose technology matches exactly (case-insensitive), such as proxmox, docker, lxc, qemu.", + }, + "resourceType": map[string]any{ + "type": "string", + "description": "Return only resources whose canonical type matches exactly (case-insensitive), such as vm, system-container, app-container, agent, storage.", + }, + }) +} + func operationsLoopStatusOutputSchema() json.RawMessage { return agentObjectOutputSchema([]string{"nextAction", "progressLabel", "steps", "patrolEvidenceCount", "patrolIssueEvidenceCount", "activeFindingCount", "pendingApprovalCount", "governedActionCount", "approvedDecisionCount", "rejectedDecisionCount", "verifiedOutcomeCount", "operationsLoopStarterCount", "assistantOperationsLoopStarterCount", "patrolOperationsLoopStarterCount", "patrolControlOperationsLoopStarterCount", "patrolControlCompletedOperationsLoopCount", "patrolControlResolvedOperationsLoopCount", "patrolControlValueState", "patrolAutonomyOperationsLoopStarterCount", "patrolAutonomyCompletedOperationsLoopCount", "patrolAutonomyResolvedOperationsLoopCount", "patrolAutonomyValueState", "proActivationOperationsLoopStarterCount", "proActivationCompletedOperationsLoopCount", "proActivationResolvedOperationsLoopCount", "proActivationValueProofState", "mcpOperationsLoopStarterCount", "externalAgentReady", "windowStart", "generatedAt"}, map[string]any{ "nextAction": stringEnumOption([]string{"run_patrol", "review_findings", "open_assistant", "review_approvals", "open_mcp", "complete"}, "Next recommended Patrol work action an agent or UI should take."), @@ -721,7 +746,7 @@ var canonicalManifest = Manifest{ { Name: "get_fleet_context", Title: "Get fleet context", - Description: "Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth.", + Description: "Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth. Optional additive filters (hasFindings, severity, technology, resourceType) narrow the result to a relevant subset so agents triaging a large fleet do not receive or page through healthy resources.", Category: "context", Method: http.MethodGet, Path: FleetContextCapabilityPath, @@ -729,6 +754,7 @@ var canonicalManifest = Manifest{ ActionMode: agentCapabilityActionModeRead, ApprovalPolicy: agentCapabilityApprovalPolicyScopeOnly, ResponseShape: "AgentFleetContext", + InputSchema: fleetContextInputSchema(), OutputSchema: fleetContextOutputSchema(), }, { diff --git a/internal/agentcapabilities/projection.go b/internal/agentcapabilities/projection.go index 695ebdf49..9bea18187 100644 --- a/internal/agentcapabilities/projection.go +++ b/internal/agentcapabilities/projection.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "regexp" "strings" ) @@ -516,6 +517,11 @@ type ProjectedCall struct { Path string Body json.RawMessage HasBody bool + // Query carries filter arguments forwarded as URL query parameters for + // GET/DELETE capabilities (which have no request body). Populated from + // leftover public arguments after path-parameter substitution. Empty for + // capabilities that declare no filter arguments. + Query url.Values } // ProjectCapabilityCall turns agent-facing tool arguments into the Pulse HTTP @@ -528,6 +534,25 @@ func ProjectCapabilityCall(cap Capability, args map[string]any) (ProjectedCall, projected := ProjectedCall{Path: path} if !CapabilityHasRequestBody(cap) { + // GET/DELETE carry non-path arguments as URL query parameters rather + // than a JSON body. Collect leftover public arguments after path + // substitution so filter-style capabilities (e.g. get_fleet_context) + // can forward filters without the agent constructing a raw query + // string. Empty when no non-path arguments are supplied — preserving + // the prior unfiltered behavior. + pathParams := PathParameterSet(cap.Path) + query := url.Values{} + for k, v := range PublicToolArguments(args) { + if pathParams[k] { + continue + } + if s, ok := v.(string); ok { + query.Set(k, s) + } + } + if len(query) > 0 { + projected.Query = query + } return projected, nil } diff --git a/internal/api/agent_resource_context.go b/internal/api/agent_resource_context.go index 3e06dceb9..98b1174ac 100644 --- a/internal/api/agent_resource_context.go +++ b/internal/api/agent_resource_context.go @@ -739,6 +739,13 @@ func (h *AgentContextHandler) HandleFleetContext(w http.ResponseWriter, r *http. } } + // Optional additive filters. All empty/unset = current behavior (return + // the full fleet). Unknown values match nothing and return an empty + // resources array, which is a valid triage answer, not an error: an agent + // asking for technology=docker on a fleet with none should see [], not a + // 400. String filters are case-insensitive and trimmed. + filter := parseFleetContextFilter(r) + for _, resource := range resources { canonical := unified.CanonicalResourceID(resource.ID) summary := AgentFleetResourceSummary{ @@ -759,6 +766,9 @@ func (h *AgentContextHandler) HandleFleetContext(w http.ResponseWriter, r *http. if count, ok := pendingByResource[canonical]; ok { summary.PendingApprovalCount = count } + if !fleetContextFilterMatches(filter, summary) { + continue + } out.Resources = append(out.Resources, summary) } @@ -766,7 +776,88 @@ func (h *AgentContextHandler) HandleFleetContext(w http.ResponseWriter, r *http. _ = json.NewEncoder(w).Encode(out) } -// HandleOperationsLoopStatus serves the content-safe status read for the shared +// fleetContextFilter captures the optional, additive query-param filters for +// GET /api/agent/fleet-context. A nil/zero field means "no filter on this +// dimension." Empty values (hasFindingsFilter set but severity empty, etc.) +// still match per their semantics below. +type fleetContextFilter struct { + // hasFindingsFilter is set when the hasFindings query param is present. + // When true, only resources with at least one active finding are kept. + // When false/absent, no finding-presence filter is applied. + hasFindingsFilter bool + hasFindings bool + // severity, when non-empty, keeps only resources with at least one finding + // at that severity (critical/warning/info). Case-insensitive, trimmed. + severity string + // technology, when non-empty, keeps only resources whose Technology matches + // (case-insensitive, trimmed). Empty technology on a resource does not match + // a non-empty filter. + technology string + // resourceType, when non-empty, keeps only resources whose Type matches + // (case-insensitive, trimmed). + resourceType string +} + +// parseFleetContextFilter reads optional fleet-context filter query params. +// follows the bare r.URL.Query().Get convention used elsewhere in the api +// package. Invalid values are silently ignored (no 400) so the endpoint +// degrades gracefully to a broader result rather than erroring on a typo. +func parseFleetContextFilter(r *http.Request) fleetContextFilter { + q := r.URL.Query() + f := fleetContextFilter{} + if raw := strings.TrimSpace(q.Get("hasFindings")); raw != "" { + // Bare sentinel match, matching the codebase convention (e.g. == "1"). + // Any value other than "true"/"1" is treated as absent to avoid + // erroring on an unfamiliar truthy spelling. + if raw == "true" || raw == "1" { + f.hasFindingsFilter = true + f.hasFindings = true + } + } + f.severity = strings.ToLower(strings.TrimSpace(q.Get("severity"))) + f.technology = strings.ToLower(strings.TrimSpace(q.Get("technology"))) + f.resourceType = strings.ToLower(strings.TrimSpace(q.Get("resourceType"))) + return f +} + +// fleetContextFilterMatches reports whether a resource summary passes the +// active fleet-context filter. An empty filter matches everything (the +// backward-compatible unfiltered behavior). +func fleetContextFilterMatches(f fleetContextFilter, s AgentFleetResourceSummary) bool { + if f.hasFindingsFilter && f.hasFindings && s.Findings.Total == 0 { + return false + } + if f.severity != "" { + switch f.severity { + case "critical": + if s.Findings.Critical == 0 { + return false + } + case "warning": + if s.Findings.Warning == 0 { + return false + } + case "info": + if s.Findings.Info == 0 { + return false + } + default: + // Unknown severity matches nothing — a valid empty result, not an + // error. An agent asking for severity=blocker on a fleet that + // uses critical/warning/info should see [], signaling the filter + // did not apply, rather than the whole fleet. + return false + } + } + if f.technology != "" && strings.ToLower(s.Technology) != f.technology { + return false + } + if f.resourceType != "" && strings.ToLower(s.ResourceType) != f.resourceType { + return false + } + return true +} + // Pulse Intelligence Patrol control loop. The canonical route is // `GET /api/agent/patrol-control/status`; `GET /api/agent/operations-loop/status` // remains as a compatibility alias. It deliberately summarizes counts and diff --git a/internal/api/agent_resource_context_test.go b/internal/api/agent_resource_context_test.go index 33de047c7..0de49460f 100644 --- a/internal/api/agent_resource_context_test.go +++ b/internal/api/agent_resource_context_test.go @@ -1396,6 +1396,189 @@ func TestHandleAgentFleetContext_RejectsNonGet(t *testing.T) { } } +// fleetFilterFixture seeds a mixed fleet for filter tests: one healthy VM +// (vm:healthy, no findings), one degraded VM (vm:critical, a critical finding), +// one docker container (container:web, a warning finding), and one LXC +// (container:lxcone, info finding). Covers every filter dimension. +func fleetFilterFixture(t *testing.T) *AgentContextHandler { + t.Helper() + h, findings, _ := fleetFixtureHandlers(t, []unified.Resource{ + {ID: "vm:healthy", Type: "vm", Name: "healthy-vm", Technology: "qemu"}, + {ID: "vm:critical", Type: "vm", Name: "critical-vm", Technology: "qemu"}, + {ID: "container:web", Type: "container", Name: "web", Technology: "docker"}, + {ID: "container:lxcone", Type: "system-container", Name: "lxcone", Technology: "lxc"}, + }) + findings.byResource["vm:critical"] = []AgentResourceFindingSnapshot{ + {ID: "c1", Severity: "critical"}, + } + findings.byResource["container:web"] = []AgentResourceFindingSnapshot{ + {ID: "w1", Severity: "warning"}, + } + findings.byResource["container:lxcone"] = []AgentResourceFindingSnapshot{ + {ID: "i1", Severity: "info"}, + } + return h +} + +func decodeFleetResponse(t *testing.T, rec *httptest.ResponseRecorder) AgentFleetContext { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String()) + } + var fleet AgentFleetContext + if err := json.NewDecoder(rec.Body).Decode(&fleet); err != nil { + t.Fatalf("decode: %v", err) + } + return fleet +} + +func fleetCanonicalIDs(fleet AgentFleetContext) []string { + ids := make([]string, 0, len(fleet.Resources)) + for _, r := range fleet.Resources { + ids = append(ids, r.CanonicalID) + } + return ids +} + +func TestHandleAgentFleetContext_HasFindingsFilter(t *testing.T) { + // hasFindings=true must drop healthy resources and keep only those with at + // least one finding — the headline triage filter. vm:healthy has none and + // must be excluded. + h := fleetFilterFixture(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?hasFindings=true", nil) + h.HandleFleetContext(rec, req) + fleet := decodeFleetResponse(t, rec) + got := fleetCanonicalIDs(fleet) + want := []string{"vm:critical", "container:web", "container:lxcone"} + if len(got) != len(want) { + t.Fatalf("hasFindings=true returned %v; want %v (healthy must be excluded)", got, want) + } + gotSet := map[string]bool{} + for _, id := range got { + gotSet[id] = true + } + for _, id := range want { + if !gotSet[id] { + t.Errorf("hasFindings=true missing %s; got %v", id, got) + } + } + if gotSet["vm:healthy"] { + t.Errorf("vm:healthy must be excluded by hasFindings=true; got %v", got) + } +} + +func TestHandleAgentFleetContext_SeverityFilter(t *testing.T) { + // severity=critical must keep only resources with a critical finding. + h := fleetFilterFixture(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?severity=critical", nil) + h.HandleFleetContext(rec, req) + fleet := decodeFleetResponse(t, rec) + got := fleetCanonicalIDs(fleet) + if len(got) != 1 || got[0] != "vm:critical" { + t.Fatalf("severity=critical returned %v; want [vm:critical]", got) + } + + // severity=warning must keep only the warning-bearing resource. + h2 := fleetFilterFixture(t) + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?severity=warning", nil) + h2.HandleFleetContext(rec2, req2) + fleet2 := decodeFleetResponse(t, rec2) + got2 := fleetCanonicalIDs(fleet2) + if len(got2) != 1 || got2[0] != "container:web" { + t.Fatalf("severity=warning returned %v; want [container:web]", got2) + } +} + +func TestHandleAgentFleetContext_TechnologyFilter(t *testing.T) { + // technology=docker (case-insensitive) must keep only the docker container. + h := fleetFilterFixture(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?technology=docker", nil) + h.HandleFleetContext(rec, req) + fleet := decodeFleetResponse(t, rec) + got := fleetCanonicalIDs(fleet) + if len(got) != 1 || got[0] != "container:web" { + t.Fatalf("technology=docker returned %v; want [container:web]", got) + } + + // Case-insensitivity: DOCKER must match the same resource. + h2 := fleetFilterFixture(t) + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?technology=DOCKER", nil) + h2.HandleFleetContext(rec2, req2) + fleet2 := decodeFleetResponse(t, rec2) + got2 := fleetCanonicalIDs(fleet2) + if len(got2) != 1 || got2[0] != "container:web" { + t.Fatalf("technology=DOCKER returned %v; want [container:web] (case-insensitive)", got2) + } +} + +func TestHandleAgentFleetContext_ResourceTypeFilter(t *testing.T) { + // resourceType=vm must keep only VMs (the two qemu VMs). + h := fleetFilterFixture(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?resourceType=vm", nil) + h.HandleFleetContext(rec, req) + fleet := decodeFleetResponse(t, rec) + got := fleetCanonicalIDs(fleet) + if len(got) != 2 { + t.Fatalf("resourceType=vm returned %v; want 2 vms", got) + } + gotSet := map[string]bool{} + for _, id := range got { + gotSet[id] = true + } + if !gotSet["vm:healthy"] || !gotSet["vm:critical"] { + t.Errorf("resourceType=vm must return both vms; got %v", got) + } +} + +func TestHandleAgentFleetContext_NoFilterReturnsAll(t *testing.T) { + // Backward compatibility: no query params returns the full fleet. + h := fleetFilterFixture(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context", nil) + h.HandleFleetContext(rec, req) + fleet := decodeFleetResponse(t, rec) + if len(fleet.Resources) != 4 { + t.Fatalf("no-filter fleet returned %d resources; want 4 (backward compat)", len(fleet.Resources)) + } +} + +func TestHandleAgentFleetContext_UnknownFilterValueReturnsEmpty(t *testing.T) { + // An unknown technology must return 200 with an empty array, not a 400. + // The signal "no resource matched this filter" is a valid triage answer. + h := fleetFilterFixture(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?technology=nonexistent", nil) + h.HandleFleetContext(rec, req) + fleet := decodeFleetResponse(t, rec) + if fleet.Resources == nil { + t.Fatal("resources is null; want non-nil empty array") + } + if len(fleet.Resources) != 0 { + t.Errorf("unknown technology returned %d resources; want 0", len(fleet.Resources)) + } +} + +func TestHandleAgentFleetContext_FiltersCompose(t *testing.T) { + // Composed filters must intersect: hasFindings=true AND technology=qemu + // returns only the degraded VM (vm:critical), excluding the healthy qemu + // VM and the non-qemu degraded resources. + h := fleetFilterFixture(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?hasFindings=true&technology=qemu", nil) + h.HandleFleetContext(rec, req) + fleet := decodeFleetResponse(t, rec) + got := fleetCanonicalIDs(fleet) + if len(got) != 1 || got[0] != "vm:critical" { + t.Fatalf("composed filter returned %v; want [vm:critical] (degraded qemu only)", got) + } +} + func TestHandleAgentOperationsLoopStatus_StartsAtPatrolWithoutIssueEvidence(t *testing.T) { h, _, _ := fleetFixtureHandlers(t, []unified.Resource{ {ID: "vm:101", Type: "vm", Name: "db-01"}, diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 565ba9b44..712a0f4ca 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -19391,6 +19391,57 @@ func TestContract_GetFleetContextCapabilityListed(t *testing.T) { } } +// TestContract_GetFleetContextDeclaresAdditiveFilters pins the agent-surface +// contract for fleet triage at scale: the capability must declare optional +// additive filter arguments (hasFindings, severity, technology, resourceType) +// so an agent can narrow a large fleet to a relevant subset without paging, +// and the GET projection layer must forward non-path arguments as URL query +// parameters (not drop them, not body them). Without the inputSchema the +// filters are undiscoverable; without query forwarding they are unreachable +// through the MCP tools/call path. +func TestContract_GetFleetContextDeclaresAdditiveFilters(t *testing.T) { + manifest := agentcapabilities.CanonicalManifest() + cap, ok := agentcapabilities.FindCapability(manifest.Capabilities, agentcapabilities.FleetContextCapabilityName) + if !ok { + t.Fatalf("manifest missing %s", agentcapabilities.FleetContextCapabilityName) + } + if len(cap.InputSchema) == 0 { + t.Fatalf("%s must declare an inputSchema so filter arguments are discoverable through the manifest", agentcapabilities.FleetContextCapabilityName) + } + var schema map[string]any + if err := json.Unmarshal(cap.InputSchema, &schema); err != nil { + t.Fatalf("%s inputSchema must be valid JSON: %v", agentcapabilities.FleetContextCapabilityName, err) + } + properties, _ := schema["properties"].(map[string]any) + for _, filter := range []string{"hasFindings", "severity", "technology", "resourceType"} { + if _, ok := properties[filter]; !ok { + t.Errorf("%s inputSchema must declare filter %q; properties = %v", agentcapabilities.FleetContextCapabilityName, filter, properties) + } + } + // None of the filters are required — omitting all returns the full fleet. + if required, _ := schema["required"].([]any); len(required) != 0 { + t.Errorf("%s filter args must all be optional (backward compat); required = %v", agentcapabilities.FleetContextCapabilityName, required) + } + + // The GET projection must forward non-path arguments as query params. Pin + // the contract end-to-end: a fleet-context call with filter args produces + // a request whose RawQuery carries them. + req, projected, err := agentcapabilities.BuildCapabilityHTTPRequest( + context.Background(), "http://pulse.local/", "token", cap, map[string]any{ + "hasFindings": "true", + "technology": "docker", + }) + if err != nil { + t.Fatalf("BuildCapabilityHTTPRequest: %v", err) + } + if projected.Query == nil || projected.Query.Get("hasFindings") != "true" || projected.Query.Get("technology") != "docker" { + t.Fatalf("GET projection must forward filter args as query params; projected = %+v", projected) + } + if req.URL.RawQuery == "" { + t.Fatalf("filter args must reach the request URL; url = %q", req.URL.String()) + } +} + // TestContract_FindingsResourceOperatorStateProviderIsWired pins the // startup wiring that gives the findings runtime access to // per-resource operator-set state via the API layer's adapter. The