mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Commit B (pulse): typed lifecycle is the only Patrol route; delete the command side doors
OrchestratorChatService is replaced, not extended: it exposes only ExecuteInvestigationStream (returning a structured OrchestratorInvestigationResult) and ListInvestigationTools. The generic ExecuteStream, SetAutonomousMode, ListAvailableTools, and the AutonomousMode request field are gone, along with OrchestratorCommandExecutor, OrchestratorApprovalStore, and the autonomy/fix-verifier/license dependency interfaces. OrchestratorDeps now carries a REQUIRED ActionBroker and no command/autonomy deps. The pulse investigation adapter is rewritten to drive ExecuteInvestigationStream, injecting proposal/finding/investigation identity from trusted context and feeding a per-org proposal catalog resolved from the tenant-bound action lifecycle (so acceptance and planning validate identically). The retired command-execution, autonomy, and command-shaped approval adapters are deleted; the router wires the broker and catalog factories onto the AI settings handler. The sixth side door is removed: PatrolService.generateRemediation- PlanFromInvestigation and its call, which copied Fix.Commands into an executable enterprise remediation plan, are deleted, pinned closed by a source-audit test. ActionCapabilityParamInfo gains Pattern (mapped by the broker), so the canonical planner's pattern validation survives the cross-repo boundary. L20 readiness assertion RA35 asserts every Patrol-initiated infrastructure mutation originates as a typed proposal and reaches execution only through the canonical action lifecycle, with unsupported proposals failing closed. Contract prose updated across ai-runtime, api-contracts, agent-lifecycle, performance-and-scalability, security-privacy, and storage-recovery. Lands together with the pulse-enterprise orchestrator migration (the replace directive means both heads move as one window).
This commit is contained in:
@@ -3946,6 +3946,93 @@
|
||||
"evidence_tier": "test-proof"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "RA35",
|
||||
"summary": "Every Patrol-initiated infrastructure mutation originates as a typed action proposal and reaches execution only through the canonical action lifecycle; unsupported proposals fail closed with no raw-command fallback.",
|
||||
"kind": "invariant",
|
||||
"blocking_level": "repo-ready",
|
||||
"proof_type": "automated",
|
||||
"lane_ids": [
|
||||
"L6",
|
||||
"L20"
|
||||
],
|
||||
"subsystem_ids": [
|
||||
"ai-runtime",
|
||||
"api-contracts"
|
||||
],
|
||||
"release_gate_ids": [],
|
||||
"proof_commands": [
|
||||
{
|
||||
"id": "ra35-typed-proposal-channel",
|
||||
"run": [
|
||||
"go",
|
||||
"test",
|
||||
"./internal/ai/tools",
|
||||
"-run",
|
||||
"TestConcurrentValidProposalsLatchAmbiguityWithNoProposal|TestProposalReplaySemanticsByInvocationID|TestFailedAttemptsWithoutSuccessAreATypedError|TestSensitiveProposalParamsRejectedWithoutEcho|TestProposeActionIsInvestigationProfileOnly|TestCapturedProposalIsImmuneToCallerMutation|TestProposalCapabilityMatchingIsExactLikePlanning|TestPatrolDetectionProfileEnforcesAllowlistedPulseState|TestPatrolInvestigationProfileIsStructurallyReadOnly",
|
||||
"-count=1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ra35-typed-proposal-contract",
|
||||
"run": [
|
||||
"go",
|
||||
"test",
|
||||
"./pkg/aicontracts",
|
||||
"-run",
|
||||
"TestOrchestratorChatServiceIsInvestigationOnly|TestOrchestratorDepsHasNoCommandOrAutonomyDeps|TestOrchestratorActionBrokerIsProposeOnly|TestActionCapabilityParamInfoCarriesPattern|TestInvestigationResultCarriesStructuredProposal",
|
||||
"-count=1"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidence": [
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/ai/chat/service_investigation.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/ai/tools/execution_profile.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/ai/tools/proposal_capture.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/ai/tools/proposal_capture_test.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/ai/tools/tools_propose.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/patrol_action_broker.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "pkg/aicontracts/action_broker.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "pkg/aicontracts/contracts_test.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "pkg/aicontracts/orchestrator_deps.go",
|
||||
"kind": "file"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidence_reference_policy": {
|
||||
@@ -5503,7 +5590,7 @@
|
||||
"status": "partial",
|
||||
"completion": {
|
||||
"state": "bounded-residual",
|
||||
"summary": "Action governance now has a first-class governed floor: tool capability declarations include typed agent-operable infrastructure onboarding over the canonical node lifecycle and LAN-discovery APIs, approval-backed action plans, plan-level preflight and dry-run posture, fail-closed action-audit normalization, bounded lifecycle events, API-visible action audit preflight, and a resource-scoped action-history surface are owned by the shared AI, API, security, Patrol, and unified-resource contracts. Broader enterprise policy execution, richer dry-run providers, multi-actor approvals, full onboarding import-plan approvals, continuous discovery-to-finding reconciliation, and deeper safe-execution contracts remain named post-RC hardening tracks.",
|
||||
"summary": "Action governance has a first-class governed floor AND the typed action lifecycle is now the only autonomous route for Patrol: investigations run under a core-owned, non-interactive, read-only execution profile and can only emit a side-effect-free typed action proposal (patrol_propose_action), which the shared action lifecycle plans, approves, and executes with plan hashing, remediation locks, plan-drift revalidation, and read-after-write verification. The enterprise orchestrator consumes a structured investigation result and submits through the required action broker; PROPOSED_FIX parsing, direct command execution, command-shaped approval creation and re-arming, and the core-generated remediation-plan path are all removed, and command text in investigation prose is inert. Registry-owned invocation descriptors classify every tool by workflow kind and mutation target and enforce control level and profile at both projection and execution. Remaining post-RC hardening: policy-scoped autonomy presets (per tenant/resource/action/window), host-update and storage-pressure workflows as the second and third complete verticals, richer dry-run providers, multi-actor approvals, and a real-lab detect-plan-execute-verify-rollback journey proof.",
|
||||
"tracking": [
|
||||
{
|
||||
"kind": "lane-followup",
|
||||
@@ -8383,6 +8470,16 @@
|
||||
"L9",
|
||||
"L12"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mobile-candidate-refresh-in-release-workflow",
|
||||
"summary": "Governed release workflow must account for Pulse Mobile candidate freshness when release work changes mobile-facing relay, auth, approval, notification, download, or customer-access surfaces: refresh the TestFlight and Play internal-testing candidate, or explicitly record why the existing mobile candidate remains valid. This does not convert the direct-link beta into public App Store or Play rollout; public mobile release remains gated by current mobile proof and the non-public rollout policy.",
|
||||
"kind": "release-policy",
|
||||
"decided_at": "2026-07-07",
|
||||
"subsystem_ids": [],
|
||||
"lane_ids": [
|
||||
"L5"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -352,7 +352,11 @@ Patrol proposal broker (`internal/api/patrol_action_broker.go`) rides that
|
||||
same API-owned lifecycle; agent lifecycle surfaces must not consume
|
||||
Patrol-origin action audits as an agent command grant or lifecycle
|
||||
execution shortcut, and must not subscribe lifecycle side effects to the
|
||||
API-owned org-scoped action-transition hook. Assistant
|
||||
API-owned org-scoped action-transition hook. The investigation
|
||||
orchestrator adapter wired here exposes only investigation-specific
|
||||
execution and listing with no autonomy or command-execution surface, so
|
||||
agent lifecycle surfaces gain no command-dispatch path through Patrol
|
||||
investigations. Assistant
|
||||
handoffs that recover a live Patrol approval by finding ID are still AI/runtime
|
||||
review context only; agent lifecycle surfaces must not treat that recovered
|
||||
approval reference as an agent command grant or host-execution shortcut.
|
||||
|
||||
@@ -4566,20 +4566,48 @@ deliberately omits org ID, requestedBy, autonomy, risk, approval-policy,
|
||||
destructive, command, and target-host fields, and has no decide or execute
|
||||
methods, so enterprise code can neither claim authorization nor dispatch;
|
||||
authorization always derives from the capability's declared policy on the
|
||||
core lifecycle. `OrchestratorDeps.ActionBroker` carries the seam;
|
||||
`CmdExecutor` and `ApprovalStore` on the same struct are migration-only
|
||||
legacy side doors that must not gain new callers and are scheduled for
|
||||
removal with the orchestrator migration. Investigations reference their
|
||||
canonical action through the additive `Action *ActionReference` fields on
|
||||
core lifecycle. `OrchestratorDeps.ActionBroker` carries the seam and is
|
||||
now REQUIRED: the enterprise factory disables the orchestrator when it is
|
||||
absent rather than falling back. The command-execution side doors are
|
||||
gone. `OrchestratorChatService` exposes only investigation-specific
|
||||
execution and listing (`ExecuteInvestigationStream` returning a
|
||||
structured `OrchestratorInvestigationResult`, and
|
||||
`ListInvestigationTools`); it has no generic `ExecuteStream`, no
|
||||
`SetAutonomousMode`, no `ListAvailableTools`, and the request type carries
|
||||
no autonomy field, so enterprise code can neither select a profile nor
|
||||
grant execution authority. `OrchestratorCommandExecutor`,
|
||||
`OrchestratorApprovalStore`, and the autonomy/fix-verifier/license
|
||||
dependency interfaces are removed along with their pulse-side adapters
|
||||
(`orchestratorChatAdapter.ExecuteStream`/`ExecuteCommand`/
|
||||
`SetAutonomousMode`, `orchestratorApprovalAdapter`,
|
||||
`autonomyLevelProviderAdapter`, `patrolFixVerifierAdapter`,
|
||||
`licenseCheckerForOrchestrator`). The enterprise investigation
|
||||
orchestrator no longer parses `PROPOSED_FIX`, runs guardrails, executes
|
||||
commands, or queues command-shaped approvals: it consumes the structured
|
||||
investigation result and, only for a non-nil proposal from a completely
|
||||
successful run, submits through the broker and persists the resulting
|
||||
`ActionReference`. Command text in investigation prose is inert
|
||||
historical text with no execution, approval, or remediation-plan pathway
|
||||
(`classifyInvestigationOutcome` reads only conclusion markers). The core
|
||||
`PatrolService.generateRemediationPlanFromInvestigation` path that copied
|
||||
`Fix.Commands` into an executable remediation plan is deleted, so no
|
||||
second command-backed remediation artifact remains. The legacy
|
||||
`aiautofix` command-approval endpoints fail closed
|
||||
(`command_fix_retired`), never approving, re-arming, or executing a
|
||||
persisted command-shaped fix. Investigations reference their canonical
|
||||
action through the additive `Action *ActionReference` fields on
|
||||
`InvestigationSession` and `InvestigationRecord`; the command-shaped
|
||||
`ProposedFix`/`ApprovalID` fields are migration-only narrative and must
|
||||
never be populated by new investigations nor re-armed into executable
|
||||
approvals. Proposal parameters live in the canonical action audit, not
|
||||
duplicated into investigation stores. Proofs:
|
||||
`ProposedFix`/`ApprovalID` fields remain readable for old persisted
|
||||
records only and are never populated by new investigations nor re-armed
|
||||
into executable approvals. Proposal parameters live in the canonical
|
||||
action audit, not duplicated into investigation stores. Proofs:
|
||||
`TestOrchestratorActionBrokerIsProposeOnly`,
|
||||
`TestOrchestratorChatServiceIsInvestigationOnly`,
|
||||
`TestOrchestratorDepsHasNoCommandOrAutonomyDeps`,
|
||||
`TestActionProposalWireShapeIsTypedAndCommandFree`, and
|
||||
`TestActionReferenceIsAdditiveOnInvestigationShapes` in
|
||||
`pkg/aicontracts/contracts_test.go`.
|
||||
`pkg/aicontracts/contracts_test.go`, plus the enterprise
|
||||
`TestProposedFixProseIsInertHistoricalText` negative regression.
|
||||
Tool-call safety classification is registry-owned through the canonical
|
||||
invocation descriptors in `internal/agentcapabilities/invocation.go`:
|
||||
every registered Pulse tool has a static or discriminator-based
|
||||
|
||||
@@ -1877,7 +1877,18 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
validates through the planner's exported canonical rules
|
||||
(`actionplanner.FindCapability` / `actionplanner.ValidateParams`),
|
||||
so a proposal that validates is exactly a proposal the planner will
|
||||
accept.
|
||||
accept; the broker's `ActionCapabilityParamInfo` projection carries
|
||||
`Pattern` alongside enum and sensitivity so that validation parity
|
||||
survives the cross-repo boundary. The Patrol proposal broker and the
|
||||
per-org proposal capability catalog are wired from the router onto the
|
||||
AI settings handler (`SetActionBrokerFactory` /
|
||||
`SetProposalCatalogFactory`), and the investigation adapter
|
||||
(`orchestratorChatAdapter`) exposes only
|
||||
`ExecuteInvestigationStream` (returning the structured investigation
|
||||
result with its typed proposal errors) and `ListInvestigationTools`.
|
||||
The retired command-execution, autonomy, and command-shaped approval
|
||||
adapters are removed, and the legacy `aiautofix` command-approval
|
||||
endpoints fail closed with `command_fix_retired`.
|
||||
Executor-owned live readiness is part of planning, not a UI precheck:
|
||||
after planner validation and before audit persistence, the lifecycle
|
||||
service must ask
|
||||
|
||||
@@ -352,6 +352,11 @@ regression protection.
|
||||
while per-action resource refresh, policy validation, agent command dispatch,
|
||||
polling verification, and audit completion must occur inside the route-local
|
||||
execute handler path.
|
||||
The Patrol action-broker and proposal-catalog factories are wired the same
|
||||
bounded way: `internal/api/router.go` installs the per-org factory closures
|
||||
on the AI settings handler once at startup, and each broker or catalog is
|
||||
constructed lazily per investigation run rather than eagerly per request, so
|
||||
the wiring adds no fan-out to the protected hot path.
|
||||
Proxmox VM/LXC lifecycle execution follows that same routed-executor budget:
|
||||
router setup may register the Proxmox executor alongside Docker / Podman,
|
||||
but it must not resolve guests, probe node agents, call `qm` / `pct`, poll
|
||||
|
||||
@@ -321,7 +321,12 @@ the `white_label` branding entitlement.
|
||||
`RequireAuth` and scope checks, resource-policy redaction pass, or
|
||||
read-only Agent-context boundary. Router glue may connect providers, but
|
||||
it must not become an alternate command path, raw provider-command path,
|
||||
config path, environment path, or secret-bearing metadata path.
|
||||
config path, environment path, or secret-bearing metadata path. The Patrol
|
||||
action-broker and proposal-catalog factory glue wired here is bound by the
|
||||
same rule: it may connect the investigation orchestrator to the tenant-bound
|
||||
action lifecycle, but it exposes only typed-proposal capture and gives the
|
||||
orchestrator no autonomy control, command execution, or command-shaped
|
||||
approval path.
|
||||
Router glue may also pass monitor-owned source freshness thresholds into
|
||||
unified-resource adapters, but those thresholds are operational cadence
|
||||
metadata only. They must not disclose credentials, command output, raw
|
||||
|
||||
@@ -119,7 +119,12 @@ metadata) that is ai-runtime config-surface, not storage or recovery
|
||||
state; a manual scoped Patrol check routed through this handler is
|
||||
investigate-only as well (it may analyze storage resources but invokes no
|
||||
backup, restore, SMART, or recovery operation, and carries resource identity
|
||||
only). Starter counts, contextual Assistant/external-agent collaboration
|
||||
only). The Patrol action-broker and proposal-catalog factories wired from
|
||||
`internal/api/router.go` onto that AI settings handler are likewise
|
||||
ai-runtime/api-owned: a storage-targeted investigation may only emit a typed
|
||||
action proposal onto the canonical action lifecycle, never a storage or
|
||||
recovery operation, so this wiring creates no storage/recovery execution
|
||||
surface. Starter counts, contextual Assistant/external-agent collaboration
|
||||
counts, Patrol control completed-loop or resolved-loop outcome evidence,
|
||||
`patrolControlValueState`, legacy `patrolAutonomy*` compatibility aliases, and
|
||||
the operator-readable `progressLabel` on that
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/safety"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/relay"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
|
||||
@@ -193,133 +192,6 @@ func (p *PatrolService) clearBlockedReason() {
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// generateRemediationPlanFromInvestigation persists a remediation plan artifact when
|
||||
// an investigation proposes a concrete fix command. This is intentionally separate
|
||||
// from the "approval" execution pipeline; it's a durable summary users can act on
|
||||
// later (often via Pulse Assistant).
|
||||
func (p *PatrolService) generateRemediationPlanFromInvestigation(findingID string) {
|
||||
p.mu.RLock()
|
||||
engine := p.remediationEngine
|
||||
orchestrator := p.investigationOrchestrator
|
||||
p.mu.RUnlock()
|
||||
|
||||
if engine == nil || orchestrator == nil || p.findings == nil || findingID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
finding := p.findings.Get(findingID)
|
||||
if finding == nil {
|
||||
return
|
||||
}
|
||||
|
||||
inv := orchestrator.GetInvestigationByFinding(findingID)
|
||||
if inv == nil || inv.ProposedFix == nil || len(inv.ProposedFix.Commands) == 0 {
|
||||
return
|
||||
}
|
||||
fix := inv.ProposedFix
|
||||
|
||||
targetHost := strings.TrimSpace(fix.TargetHost)
|
||||
if targetHost == "" {
|
||||
targetHost = "local"
|
||||
}
|
||||
|
||||
// Map investigation risk strings into remediation risk levels.
|
||||
riskLevel := aicontracts.RiskMedium
|
||||
switch strings.ToLower(strings.TrimSpace(fix.RiskLevel)) {
|
||||
case "low":
|
||||
riskLevel = aicontracts.RiskLow
|
||||
case "medium":
|
||||
riskLevel = aicontracts.RiskMedium
|
||||
case "high":
|
||||
riskLevel = aicontracts.RiskHigh
|
||||
case "critical":
|
||||
riskLevel = aicontracts.RiskHigh
|
||||
}
|
||||
|
||||
steps := make([]aicontracts.RemediationStep, 0, 2+len(fix.Commands))
|
||||
steps = append(steps, aicontracts.RemediationStep{
|
||||
Order: 1,
|
||||
Description: "Review the finding context and confirm the proposed fix is appropriate",
|
||||
})
|
||||
|
||||
blockedCount := 0
|
||||
order := 2
|
||||
for _, raw := range fix.Commands {
|
||||
cmd := strings.TrimSpace(raw)
|
||||
if cmd == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
stepCommand := cmd
|
||||
stepDesc := fmt.Sprintf("Run the proposed fix on %s", targetHost)
|
||||
if safety.IsBlockedCommand(cmd) {
|
||||
// Don't store blocked commands in the remediation engine; keep the plan as an
|
||||
// artifact for users to review and apply manually (typically via Assistant).
|
||||
stepCommand = ""
|
||||
stepDesc = fmt.Sprintf("Blocked command proposed by investigation (review and apply manually): %s", cmd)
|
||||
blockedCount++
|
||||
}
|
||||
|
||||
steps = append(steps, aicontracts.RemediationStep{
|
||||
Order: order,
|
||||
Description: stepDesc,
|
||||
Command: stepCommand,
|
||||
Target: targetHost,
|
||||
})
|
||||
order++
|
||||
}
|
||||
|
||||
steps = append(steps, aicontracts.RemediationStep{
|
||||
Order: order,
|
||||
Description: "Verify the issue is resolved (re-check metrics/logs, confirm service health)",
|
||||
})
|
||||
|
||||
description := strings.TrimSpace(fix.Rationale)
|
||||
if description == "" {
|
||||
description = strings.TrimSpace(inv.Summary)
|
||||
}
|
||||
if description == "" {
|
||||
description = finding.Description
|
||||
}
|
||||
|
||||
plan := &aicontracts.RemediationPlan{
|
||||
FindingID: finding.ID,
|
||||
ResourceID: finding.ResourceID,
|
||||
Title: fmt.Sprintf("Investigation Fix: %s", finding.Title),
|
||||
Description: description,
|
||||
Category: aicontracts.CategoryGuided,
|
||||
RiskLevel: riskLevel,
|
||||
Steps: steps,
|
||||
Rationale: fix.Description,
|
||||
}
|
||||
|
||||
// Patrol findings are often reviewed hours/days later; keep investigation-derived
|
||||
// plans around longer than the default ephemeral remediation TTL.
|
||||
expires := time.Now().Add(7 * 24 * time.Hour)
|
||||
plan.ExpiresAt = &expires
|
||||
|
||||
if blockedCount > 0 {
|
||||
plan.Warnings = append(plan.Warnings, "Investigation suggested one or more commands that are blocked by safety policy. Review carefully and apply manually (prefer Pulse Assistant).")
|
||||
}
|
||||
|
||||
if err := engine.CreatePlan(plan); err != nil {
|
||||
// As a fallback, keep the plan as purely informational so it can still be
|
||||
// surfaced to the user without enabling remediation engine execution.
|
||||
for i := range plan.Steps {
|
||||
if plan.Steps[i].Command == "" {
|
||||
continue
|
||||
}
|
||||
plan.Steps[i].Description = fmt.Sprintf("%s: %s", plan.Steps[i].Description, plan.Steps[i].Command)
|
||||
plan.Steps[i].Command = ""
|
||||
}
|
||||
plan.RiskLevel = aicontracts.RiskMedium
|
||||
plan.Warnings = append(plan.Warnings, fmt.Sprintf("Failed to store command steps for automated remediation: %v", err))
|
||||
if createErr := engine.CreatePlan(plan); createErr != nil {
|
||||
log.Warn().Err(createErr).Str("findingID", finding.ID).Msg("failed to create fallback remediation plan")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetFindingsForResource returns active findings for a specific resource
|
||||
func (p *PatrolService) GetFindingsForResource(resourceID string) []*Finding {
|
||||
findings := p.findings.GetByResource(resourceID)
|
||||
@@ -1277,9 +1149,10 @@ func (p *PatrolService) MaybeInvestigateFinding(f *Finding) {
|
||||
}
|
||||
}
|
||||
|
||||
// Investigation finished successfully. If it produced a proposed fix, persist a
|
||||
// remediation plan artifact so the user can review and execute later.
|
||||
p.generateRemediationPlanFromInvestigation(f.ID)
|
||||
// Typed remediation now flows exclusively through the action
|
||||
// proposal channel: the investigation's ActionReference points at
|
||||
// the canonical action audit, so no command-shaped remediation
|
||||
// plan artifact is generated from investigation prose.
|
||||
}()
|
||||
|
||||
log.Info().
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1348,3 +1349,23 @@ func TestPatrolFindingCreatorAdapter_ResolveFinding_FallsThroughWhenNoVerifierAv
|
||||
t.Fatalf("expected finding to be resolved, got %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateRemediationPlanFromInvestigationIsDeleted pins the sixth
|
||||
// side door closed: the path that copied Fix.Commands into an executable
|
||||
// enterprise remediation plan must not return. Typed remediation flows
|
||||
// only through the action proposal channel.
|
||||
func TestGenerateRemediationPlanFromInvestigationIsDeleted(t *testing.T) {
|
||||
source, err := os.ReadFile("patrol_findings.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read patrol_findings.go: %v", err)
|
||||
}
|
||||
src := string(source)
|
||||
for _, forbidden := range []string{
|
||||
"generateRemediationPlanFromInvestigation",
|
||||
"inv.ProposedFix.Commands",
|
||||
} {
|
||||
if strings.Contains(src, forbidden) {
|
||||
t.Fatalf("command-backed remediation path must stay deleted; found %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+130
-128
@@ -63,6 +63,8 @@ type AISettingsHandler struct {
|
||||
readState unifiedresources.ReadState
|
||||
unifiedResourceProvider ai.UnifiedResourceProvider
|
||||
resourceStoreProvider func(orgID string) (unifiedresources.ResourceStore, error)
|
||||
actionBrokerFactory func(orgID string) aicontracts.OrchestratorActionBroker
|
||||
proposalCatalogFactory func(orgID string) tools.ProposalCatalog
|
||||
metadataProvider ai.MetadataProvider
|
||||
patrolThresholdProvider ai.ThresholdProvider
|
||||
metricsHistoryProvider ai.MetricsHistoryProvider
|
||||
@@ -133,6 +135,57 @@ func (h *AISettingsHandler) SetResourceStoreProvider(provider func(orgID string)
|
||||
h.resourceStoreProvider = provider
|
||||
}
|
||||
|
||||
// SetActionBrokerFactory installs the per-org typed action proposal broker
|
||||
// used by the investigation orchestrator. Core-owned: it binds the tenant
|
||||
// and the fixed Patrol actor, so enterprise code can only propose typed
|
||||
// capabilities, never dispatch commands or claim authority.
|
||||
func (h *AISettingsHandler) SetActionBrokerFactory(factory func(orgID string) aicontracts.OrchestratorActionBroker) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.stateMu.Lock()
|
||||
defer h.stateMu.Unlock()
|
||||
h.actionBrokerFactory = factory
|
||||
}
|
||||
|
||||
// SetProposalCatalogFactory installs the per-org capability catalog used
|
||||
// for proposal validation, resolved from the tenant-bound action
|
||||
// lifecycle service so acceptance and planning share one contract.
|
||||
func (h *AISettingsHandler) SetProposalCatalogFactory(factory func(orgID string) tools.ProposalCatalog) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.stateMu.Lock()
|
||||
defer h.stateMu.Unlock()
|
||||
h.proposalCatalogFactory = factory
|
||||
}
|
||||
|
||||
func (h *AISettingsHandler) actionBrokerFor(orgID string) aicontracts.OrchestratorActionBroker {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
h.stateMu.RLock()
|
||||
factory := h.actionBrokerFactory
|
||||
h.stateMu.RUnlock()
|
||||
if factory == nil {
|
||||
return nil
|
||||
}
|
||||
return factory(orgID)
|
||||
}
|
||||
|
||||
func (h *AISettingsHandler) proposalCatalogFor(orgID string) tools.ProposalCatalog {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
h.stateMu.RLock()
|
||||
factory := h.proposalCatalogFactory
|
||||
h.stateMu.RUnlock()
|
||||
if factory == nil {
|
||||
return nil
|
||||
}
|
||||
return factory(orgID)
|
||||
}
|
||||
|
||||
func (h *AISettingsHandler) stateRefs() (
|
||||
*config.MultiTenantPersistence,
|
||||
*monitoring.MultiTenantMonitor,
|
||||
@@ -1954,7 +2007,10 @@ func (h *AISettingsHandler) setupInvestigationOrchestrator(orgID string, svc *ai
|
||||
})
|
||||
|
||||
// Build local adapters that implement aicontracts.Orchestrator* interfaces
|
||||
chatAdapter := &orchestratorChatAdapter{svc: chatService}
|
||||
chatAdapter := &orchestratorChatAdapter{
|
||||
svc: chatService,
|
||||
catalog: h.proposalCatalogFor(orgID),
|
||||
}
|
||||
|
||||
// Create findings store adapter
|
||||
findingsStore := patrol.GetFindings()
|
||||
@@ -1964,16 +2020,6 @@ func (h *AISettingsHandler) setupInvestigationOrchestrator(orgID string, svc *ai
|
||||
}
|
||||
findingsAdapter := &orchestratorFindingsAdapter{store: &findingsStoreWrapper{store: findingsStore}}
|
||||
|
||||
// Create approval adapter from the global approval store
|
||||
var approvalAdapter aicontracts.OrchestratorApprovalStore
|
||||
if approvalStoreInst := approval.GetStore(); approvalStoreInst != nil {
|
||||
approvalAdapter = &orchestratorApprovalAdapter{
|
||||
store: approvalStoreInst,
|
||||
orgID: approval.NormalizeOrgID(orgID),
|
||||
actionAuditStore: chatService.GetActionAuditStore(),
|
||||
}
|
||||
}
|
||||
|
||||
// Get config for investigation settings
|
||||
cfg := svc.GetConfig()
|
||||
invConfig := aicontracts.DefaultInvestigationConfig()
|
||||
@@ -2008,18 +2054,16 @@ func (h *AISettingsHandler) setupInvestigationOrchestrator(orgID string, svc *ai
|
||||
infraContext = knowledgeStore
|
||||
}
|
||||
|
||||
// Build deps struct and call factory
|
||||
// Build deps struct and call factory. The typed action broker is
|
||||
// REQUIRED: without it the factory disables the orchestrator - there
|
||||
// is no command-execution fallback.
|
||||
deps := aicontracts.OrchestratorDeps{
|
||||
ChatService: chatAdapter,
|
||||
CmdExecutor: chatAdapter, // ChatServiceAdapter implements both interfaces
|
||||
Store: store,
|
||||
FindingsStore: findingsAdapter,
|
||||
ApprovalStore: approvalAdapter,
|
||||
ActionBroker: h.actionBrokerFor(approval.NormalizeOrgID(orgID)),
|
||||
Config: invConfig,
|
||||
InfraContext: infraContext,
|
||||
Autonomy: &autonomyLevelProviderAdapter{svc: svc},
|
||||
FixVerifier: &patrolFixVerifierAdapter{patrol: patrol},
|
||||
License: &licenseCheckerForOrchestrator{svc: svc},
|
||||
Metrics: &patrolMetricsCallbackAdapter{},
|
||||
}
|
||||
|
||||
@@ -2041,9 +2085,16 @@ func (h *AISettingsHandler) setupInvestigationOrchestrator(orgID string, svc *ai
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// orchestratorChatAdapter wraps *chat.Service to implement
|
||||
// aicontracts.OrchestratorChatService and OrchestratorCommandExecutor.
|
||||
// aicontracts.OrchestratorChatService. It exposes only the
|
||||
// investigation-specific execution/listing surface: there is no generic
|
||||
// chat execution, no autonomy control, and no command execution here -
|
||||
// the typed proposal channel is the only route from an investigation to
|
||||
// an infrastructure mutation.
|
||||
type orchestratorChatAdapter struct {
|
||||
svc *chat.Service
|
||||
// catalog resolves advertised resource capabilities for proposal
|
||||
// validation, from the tenant-bound action lifecycle service.
|
||||
catalog tools.ProposalCatalog
|
||||
}
|
||||
|
||||
func (a *orchestratorChatAdapter) CreateSession(ctx context.Context) (*aicontracts.OrchestratorChatSession, error) {
|
||||
@@ -2054,25 +2105,73 @@ func (a *orchestratorChatAdapter) CreateSession(ctx context.Context) (*aicontrac
|
||||
return &aicontracts.OrchestratorChatSession{ID: session.ID}, nil
|
||||
}
|
||||
|
||||
func (a *orchestratorChatAdapter) ExecuteStream(ctx context.Context, req aicontracts.OrchestratorExecuteRequest, callback aicontracts.OrchestratorStreamCallback) error {
|
||||
func (a *orchestratorChatAdapter) ExecuteInvestigationStream(ctx context.Context, req aicontracts.OrchestratorInvestigationRequest, callback aicontracts.OrchestratorStreamCallback) (*aicontracts.OrchestratorInvestigationResult, error) {
|
||||
if a.svc == nil {
|
||||
return fmt.Errorf("chat service is nil")
|
||||
return nil, fmt.Errorf("chat service is nil")
|
||||
}
|
||||
if !a.svc.IsRunning() {
|
||||
return fmt.Errorf("chat service is not running")
|
||||
return nil, fmt.Errorf("chat service is not running")
|
||||
}
|
||||
chatReq := chat.ExecuteRequest{
|
||||
Prompt: req.Prompt,
|
||||
SessionID: req.SessionID,
|
||||
MaxTurns: req.MaxTurns,
|
||||
AutonomousMode: req.AutonomousMode,
|
||||
}
|
||||
return a.svc.ExecuteStream(ctx, chatReq, func(event chat.StreamEvent) {
|
||||
runResult, err := a.svc.ExecuteInvestigationStream(ctx, chat.InvestigationRunRequest{
|
||||
SessionID: req.SessionID,
|
||||
Prompt: req.Prompt,
|
||||
SystemPrompt: req.SystemPrompt,
|
||||
MaxTurns: req.MaxTurns,
|
||||
ExecutionID: req.ExecutionID,
|
||||
Identity: tools.ProposalIdentity{
|
||||
ProposalID: req.ProposalID,
|
||||
FindingID: req.FindingID,
|
||||
InvestigationID: req.InvestigationID,
|
||||
EvidenceIDs: req.EvidenceIDs,
|
||||
},
|
||||
Catalog: a.catalog,
|
||||
}, func(event chat.StreamEvent) {
|
||||
callback(aicontracts.OrchestratorStreamEvent{
|
||||
Type: event.Type,
|
||||
Data: event.Data,
|
||||
})
|
||||
})
|
||||
if runResult == nil {
|
||||
return nil, mapInvestigationProposalError(err)
|
||||
}
|
||||
result := &aicontracts.OrchestratorInvestigationResult{
|
||||
Content: runResult.Content,
|
||||
FailedProposalAttempts: runResult.FailedProposalAttempts,
|
||||
InputTokens: runResult.InputTokens,
|
||||
OutputTokens: runResult.OutputTokens,
|
||||
}
|
||||
if runResult.Proposal != nil {
|
||||
captured := runResult.Proposal
|
||||
result.Proposal = &aicontracts.ActionProposal{
|
||||
ProposalID: captured.Identity.ProposalID,
|
||||
FindingID: captured.Identity.FindingID,
|
||||
InvestigationID: captured.Identity.InvestigationID,
|
||||
ResourceID: captured.ResourceID,
|
||||
CapabilityName: captured.CapabilityName,
|
||||
Params: captured.Params,
|
||||
Reason: captured.Reason,
|
||||
EvidenceIDs: captured.Identity.EvidenceIDs,
|
||||
}
|
||||
}
|
||||
return result, mapInvestigationProposalError(err)
|
||||
}
|
||||
|
||||
// mapInvestigationProposalError projects the core proposal-channel errors
|
||||
// onto the public contract sentinels so enterprise outcome mapping can
|
||||
// key on errors.Is without importing internal packages.
|
||||
func mapInvestigationProposalError(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, tools.ErrProposalAmbiguous):
|
||||
return aicontracts.ErrInvestigationProposalAmbiguous
|
||||
case errors.Is(err, tools.ErrProposalIntegrity):
|
||||
return aicontracts.ErrInvestigationProposalIntegrity
|
||||
case errors.Is(err, tools.ErrProposalAttemptsFailed):
|
||||
return aicontracts.ErrInvestigationProposalAttemptsFailed
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:dupl // mirrors chatServiceAdapter.GetMessages: same source messages mapped onto a deliberately separate output contract that may diverge
|
||||
@@ -2106,24 +2205,12 @@ func (a *orchestratorChatAdapter) DeleteSession(ctx context.Context, sessionID s
|
||||
return a.svc.DeleteSession(ctx, sessionID)
|
||||
}
|
||||
|
||||
func (a *orchestratorChatAdapter) ListAvailableTools(ctx context.Context, prompt string) []string {
|
||||
func (a *orchestratorChatAdapter) ListInvestigationTools(ctx context.Context) []string {
|
||||
_ = ctx
|
||||
if a.svc == nil {
|
||||
return nil
|
||||
}
|
||||
return a.svc.ListAvailableTools(ctx, prompt)
|
||||
}
|
||||
|
||||
func (a *orchestratorChatAdapter) SetAutonomousMode(enabled bool) {
|
||||
if a.svc != nil {
|
||||
a.svc.SetAutonomousMode(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *orchestratorChatAdapter) ExecuteCommand(ctx context.Context, command, targetHost string) (string, int, error) {
|
||||
if a.svc == nil {
|
||||
return "", -1, fmt.Errorf("chat service not available")
|
||||
}
|
||||
return a.svc.ExecuteCommand(ctx, command, targetHost)
|
||||
return a.svc.ListInvestigationTools()
|
||||
}
|
||||
|
||||
// orchestratorFindingsAdapter wraps findingsStoreWrapper to implement
|
||||
@@ -2173,59 +2260,6 @@ func (a *orchestratorFindingsAdapter) Update(f *aicontracts.Finding) bool {
|
||||
)
|
||||
}
|
||||
|
||||
// orchestratorApprovalAdapter wraps *approval.Store to implement
|
||||
// aicontracts.OrchestratorApprovalStore.
|
||||
type orchestratorApprovalAdapter struct {
|
||||
store *approval.Store
|
||||
orgID string
|
||||
actionAuditStore unifiedresources.ResourceStore
|
||||
}
|
||||
|
||||
func (a *orchestratorApprovalAdapter) Create(appr *aicontracts.OrchestratorApproval) error {
|
||||
if a.store == nil {
|
||||
return nil
|
||||
}
|
||||
riskLevel := approval.RiskLow
|
||||
switch appr.RiskLevel {
|
||||
case "low":
|
||||
riskLevel = approval.RiskLow
|
||||
case "medium":
|
||||
riskLevel = approval.RiskMedium
|
||||
case "high", "critical":
|
||||
riskLevel = approval.RiskHigh
|
||||
}
|
||||
req := &approval.ApprovalRequest{
|
||||
OrgID: a.orgID,
|
||||
ID: appr.ID,
|
||||
ToolID: "investigation_fix",
|
||||
Command: appr.Command,
|
||||
TargetType: "investigation",
|
||||
TargetID: appr.FindingID,
|
||||
TargetName: strings.TrimSpace(appr.TargetHost),
|
||||
Context: "Automated fix from patrol investigation: " + appr.Description,
|
||||
RiskLevel: riskLevel,
|
||||
}
|
||||
if req.TargetName == "" {
|
||||
req.TargetName = appr.Description
|
||||
}
|
||||
tools.AttachApprovalActionPlan(req, time.Now().UTC())
|
||||
if err := a.store.CreateApproval(req); err != nil {
|
||||
return err
|
||||
}
|
||||
tools.RecordPendingApprovalAction(a.actionAuditStore, req)
|
||||
return nil
|
||||
}
|
||||
|
||||
// patrolFixVerifierAdapter wraps *ai.PatrolService to implement
|
||||
// aicontracts.OrchestratorFixVerifier.
|
||||
type patrolFixVerifierAdapter struct {
|
||||
patrol *ai.PatrolService
|
||||
}
|
||||
|
||||
func (v *patrolFixVerifierAdapter) VerifyFixResolved(ctx context.Context, finding *aicontracts.Finding) (bool, error) {
|
||||
return v.patrol.VerifyFixResolved(ctx, finding.ResourceID, finding.ResourceType, finding.Key, finding.ID)
|
||||
}
|
||||
|
||||
// patrolMetricsCallbackAdapter implements aicontracts.OrchestratorMetricsCallback
|
||||
// by delegating to the global PatrolMetrics singleton.
|
||||
type patrolMetricsCallbackAdapter struct{}
|
||||
@@ -2238,15 +2272,6 @@ func (c *patrolMetricsCallbackAdapter) RecordFixVerification(result string) {
|
||||
ai.GetPatrolMetrics().RecordFixVerification(result)
|
||||
}
|
||||
|
||||
// licenseCheckerForOrchestrator adapts *ai.Service to aicontracts.OrchestratorLicenseChecker
|
||||
type licenseCheckerForOrchestrator struct {
|
||||
svc *ai.Service
|
||||
}
|
||||
|
||||
func (l *licenseCheckerForOrchestrator) HasFeature(feature string) bool {
|
||||
return l.svc.HasLicenseFeature(feature)
|
||||
}
|
||||
|
||||
// findingsStoreWrapper wraps *ai.FindingsStore to implement aicontracts.OrchestratorAIFindingsStore
|
||||
type findingsStoreWrapper struct {
|
||||
store *ai.FindingsStore
|
||||
@@ -2270,29 +2295,6 @@ func (w *findingsStoreWrapper) UpdateInvestigation(id, sessionID, status, outcom
|
||||
return w.store.UpdateInvestigation(id, sessionID, status, outcome, lastInvestigatedAt, attempts)
|
||||
}
|
||||
|
||||
// autonomyLevelProviderAdapter provides current autonomy level from config for re-checking before fix execution
|
||||
type autonomyLevelProviderAdapter struct {
|
||||
svc *ai.Service
|
||||
}
|
||||
|
||||
func (a *autonomyLevelProviderAdapter) GetCurrentAutonomyLevel() string {
|
||||
if a.svc == nil {
|
||||
return config.PatrolAutonomyMonitor
|
||||
}
|
||||
return a.svc.GetEffectivePatrolAutonomyLevel()
|
||||
}
|
||||
|
||||
func (a *autonomyLevelProviderAdapter) IsFullModeUnlocked() bool {
|
||||
if a.svc == nil {
|
||||
return false
|
||||
}
|
||||
cfg := a.svc.GetConfig()
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
return cfg.PatrolFullModeUnlocked
|
||||
}
|
||||
|
||||
// AISettingsResponse is returned by GET /api/settings/ai
|
||||
// API keys are masked for security
|
||||
type AISettingsResponse struct {
|
||||
|
||||
@@ -515,54 +515,3 @@ func TestAISettingsHandler_GetAIService_NonDefaultWithTenantMonitorWithoutPersis
|
||||
t.Fatal("expected fail-closed tenant service to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutonomyLevelProviderAdapter(t *testing.T) {
|
||||
adapter := &autonomyLevelProviderAdapter{}
|
||||
if got := adapter.GetCurrentAutonomyLevel(); got != config.PatrolAutonomyMonitor {
|
||||
t.Fatalf("expected default autonomy %q, got %q", config.PatrolAutonomyMonitor, got)
|
||||
}
|
||||
if adapter.IsFullModeUnlocked() {
|
||||
t.Fatalf("expected full mode locked by default")
|
||||
}
|
||||
|
||||
tmp := t.TempDir()
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
aiCfg := config.NewDefaultAIConfig()
|
||||
aiCfg.PatrolAutonomyLevel = config.PatrolAutonomyAssisted
|
||||
aiCfg.PatrolFullModeUnlocked = true
|
||||
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
|
||||
t.Fatalf("SaveAIConfig: %v", err)
|
||||
}
|
||||
|
||||
svc := ai.NewService(persistence, nil)
|
||||
if err := svc.LoadConfig(); err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
adapter = &autonomyLevelProviderAdapter{svc: svc}
|
||||
|
||||
if got := adapter.GetCurrentAutonomyLevel(); got != config.PatrolAutonomyAssisted {
|
||||
t.Fatalf("expected autonomy %q, got %q", config.PatrolAutonomyAssisted, got)
|
||||
}
|
||||
if !adapter.IsFullModeUnlocked() {
|
||||
t.Fatalf("expected full mode unlocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseCheckerForOrchestrator(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
svc := ai.NewService(persistence, nil)
|
||||
if err := svc.LoadConfig(); err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
|
||||
checker := &licenseCheckerForOrchestrator{svc: svc}
|
||||
if !checker.HasFeature("pro") {
|
||||
t.Fatalf("expected default license to allow feature")
|
||||
}
|
||||
|
||||
svc.SetLicenseChecker(stubLicenseChecker{allow: false})
|
||||
if checker.HasFeature("pro") {
|
||||
t.Fatalf("expected license checker to deny feature")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3278,60 +3278,6 @@ func TestAISettingsHandler_Approvals(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestOrchestratorApprovalAdapterRecordsPendingActionAudit(t *testing.T) {
|
||||
approvalStore, err := approval.NewStore(approval.StoreConfig{
|
||||
DataDir: t.TempDir(),
|
||||
DisablePersistence: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
actionStore := unifiedresources.NewMemoryStore()
|
||||
adapter := &orchestratorApprovalAdapter{
|
||||
store: approvalStore,
|
||||
orgID: "tenant-1",
|
||||
actionAuditStore: actionStore,
|
||||
}
|
||||
|
||||
err = adapter.Create(&aicontracts.OrchestratorApproval{
|
||||
ID: "approval-investigation-1",
|
||||
FindingID: "finding-123",
|
||||
Command: "systemctl restart nginx",
|
||||
TargetHost: "web-1",
|
||||
Description: "Restart nginx after Patrol investigation",
|
||||
RiskLevel: "high",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req, ok := approvalStore.GetApproval("approval-investigation-1")
|
||||
require.True(t, ok, "expected approval to be persisted")
|
||||
require.NotNil(t, req.Plan, "expected Patrol investigation approval to carry a governed action plan")
|
||||
require.NotEmpty(t, req.Plan.ActionID)
|
||||
require.Equal(t, req.ID, req.Plan.RequestID)
|
||||
require.True(t, req.Plan.RequiresApproval)
|
||||
require.Equal(t, unifiedresources.ApprovalAdmin, req.Plan.ApprovalPolicy)
|
||||
require.NotNil(t, req.Preflight)
|
||||
require.NotNil(t, req.ContextConfidence)
|
||||
|
||||
audit, ok, err := actionStore.GetActionAudit(req.Plan.ActionID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok, "expected queued Patrol fix approval to seed action audit")
|
||||
require.Equal(t, unifiedresources.ActionStatePending, audit.State)
|
||||
require.Equal(t, req.ID, audit.Request.RequestID)
|
||||
require.Equal(t, "investigation:finding-123", audit.Request.ResourceID)
|
||||
require.Equal(t, "pulse_patrol", audit.Request.RequestedBy)
|
||||
require.NotNil(t, audit.Plan.Preflight)
|
||||
|
||||
events, err := actionStore.GetActionLifecycleEvents(req.Plan.ActionID, time.Time{}, 10)
|
||||
require.NoError(t, err)
|
||||
states := map[unifiedresources.ActionState]bool{}
|
||||
for _, event := range events {
|
||||
states[event.State] = true
|
||||
require.Equal(t, "pulse_patrol", event.Actor)
|
||||
}
|
||||
require.True(t, states[unifiedresources.ActionStatePlanned], "missing planned lifecycle event: %#v", events)
|
||||
require.True(t, states[unifiedresources.ActionStatePending], "missing pending lifecycle event: %#v", events)
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_Approvals_RejectCrossOrgAccess(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
|
||||
@@ -607,13 +607,29 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
|
||||
}
|
||||
|
||||
settingsHandlerText := string(settingsHandlerSource)
|
||||
// Patrol remediation now enters as a typed action proposal through the
|
||||
// required action broker; the investigation adapter carries no
|
||||
// command execution, no autonomy, and no command-shaped approval
|
||||
// seeding.
|
||||
for _, required := range []string{
|
||||
"actionAuditStore: chatService.GetActionAuditStore()",
|
||||
"tools.AttachApprovalActionPlan(req, time.Now().UTC())",
|
||||
"tools.RecordPendingApprovalAction(a.actionAuditStore, req)",
|
||||
"ActionBroker: h.actionBrokerFor(approval.NormalizeOrgID(orgID))",
|
||||
"catalog: h.proposalCatalogFor(orgID)",
|
||||
"func (a *orchestratorChatAdapter) ExecuteInvestigationStream(",
|
||||
} {
|
||||
if !strings.Contains(settingsHandlerText, required) {
|
||||
t.Fatalf("ai_handlers.go must preserve Patrol queued-fix action-audit seeding: missing %q", required)
|
||||
t.Fatalf("ai_handlers.go must wire the typed investigation proposal channel: missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"func (a *orchestratorChatAdapter) ExecuteStream(",
|
||||
"func (a *orchestratorChatAdapter) SetAutonomousMode(",
|
||||
"func (a *orchestratorChatAdapter) ExecuteCommand(",
|
||||
"orchestratorApprovalAdapter",
|
||||
"autonomyLevelProviderAdapter",
|
||||
"patrolFixVerifierAdapter",
|
||||
} {
|
||||
if strings.Contains(settingsHandlerText, forbidden) {
|
||||
t.Fatalf("ai_handlers.go must not retain a retired command/autonomy adapter: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ func (b *patrolActionBroker) Capabilities(ctx context.Context, resourceID string
|
||||
Type: param.Type,
|
||||
Required: param.Required,
|
||||
Enum: append([]string(nil), param.Enum...),
|
||||
Pattern: param.Pattern,
|
||||
Description: param.Description,
|
||||
Sensitive: param.IsSensitive,
|
||||
})
|
||||
|
||||
@@ -658,6 +658,15 @@ func (r *Router) setupRoutes() {
|
||||
r.aiSettingsHandler = NewAISettingsHandler(r.multiTenant, r.mtMonitor, r.agentExecServer)
|
||||
if r.resourceHandlers != nil {
|
||||
r.aiSettingsHandler.SetResourceStoreProvider(r.resourceHandlers.getStore)
|
||||
resourceHandlers := r.resourceHandlers
|
||||
r.aiSettingsHandler.SetActionBrokerFactory(func(orgID string) aicontracts.OrchestratorActionBroker {
|
||||
return NewPatrolActionBroker(orgID, resourceHandlers)
|
||||
})
|
||||
r.aiSettingsHandler.SetProposalCatalogFactory(func(orgID string) tools.ProposalCatalog {
|
||||
return func(ctx context.Context, resourceID string) ([]unifiedresources.ResourceCapability, error) {
|
||||
return resourceHandlers.ActionLifecycle().Capabilities(ctx, orgID, resourceID)
|
||||
}
|
||||
})
|
||||
}
|
||||
r.aiSettingsHandler.SetConfig(r.config)
|
||||
// Inject state provider so AI has access to full infrastructure context (VMs, containers, IPs)
|
||||
|
||||
@@ -59,6 +59,7 @@ type ActionCapabilityParamInfo struct {
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Sensitive bool `json:"sensitive,omitempty"`
|
||||
}
|
||||
|
||||
@@ -249,3 +249,56 @@ func TestOrchestratorDepsExposesTypedActionBroker(t *testing.T) {
|
||||
t.Fatalf("ActionBroker field type = %v, want OrchestratorActionBroker", field.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestratorChatServiceIsInvestigationOnly(t *testing.T) {
|
||||
svcType := reflect.TypeOf((*OrchestratorChatService)(nil)).Elem()
|
||||
// The generic-execution and autonomy surface is gone: no method may
|
||||
// carry autonomy, generic execution, or a broad tool listing.
|
||||
for _, forbidden := range []string{"ExecuteStream", "SetAutonomousMode", "ListAvailableTools"} {
|
||||
if _, ok := svcType.MethodByName(forbidden); ok {
|
||||
t.Fatalf("OrchestratorChatService must not expose %s", forbidden)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{"ExecuteInvestigationStream", "ListInvestigationTools"} {
|
||||
if _, ok := svcType.MethodByName(required); !ok {
|
||||
t.Fatalf("OrchestratorChatService must expose %s", required)
|
||||
}
|
||||
}
|
||||
// The investigation request must carry no autonomy field.
|
||||
reqType := reflect.TypeOf(OrchestratorInvestigationRequest{})
|
||||
for i := 0; i < reqType.NumField(); i++ {
|
||||
name := strings.ToLower(reqType.Field(i).Name)
|
||||
if strings.Contains(name, "autonom") {
|
||||
t.Fatalf("investigation request must carry no autonomy field, found %s", reqType.Field(i).Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchestratorDepsHasNoCommandOrAutonomyDeps(t *testing.T) {
|
||||
depsType := reflect.TypeOf(OrchestratorDeps{})
|
||||
for _, forbidden := range []string{"CmdExecutor", "ApprovalStore", "Autonomy", "FixVerifier", "License"} {
|
||||
if _, ok := depsType.FieldByName(forbidden); ok {
|
||||
t.Fatalf("OrchestratorDeps must not expose %s after the typed-lifecycle migration", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionCapabilityParamInfoCarriesPattern(t *testing.T) {
|
||||
// Canonical planner validation enforces Pattern; the cross-repo
|
||||
// capability projection must carry it or proposal validation parity
|
||||
// dies at the boundary.
|
||||
if _, ok := reflect.TypeOf(ActionCapabilityParamInfo{}).FieldByName("Pattern"); !ok {
|
||||
t.Fatal("ActionCapabilityParamInfo must carry Pattern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvestigationResultCarriesStructuredProposal(t *testing.T) {
|
||||
resultType := reflect.TypeOf(OrchestratorInvestigationResult{})
|
||||
field, ok := resultType.FieldByName("Proposal")
|
||||
if !ok {
|
||||
t.Fatal("investigation result must carry a structured Proposal")
|
||||
}
|
||||
if field.Type != reflect.TypeOf((*ActionProposal)(nil)) {
|
||||
t.Fatalf("Proposal field type = %v, want *ActionProposal", field.Type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package aicontracts
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
||||
@@ -19,50 +20,69 @@ import (
|
||||
// OrchestratorChatService provides AI chat session management for investigations.
|
||||
type OrchestratorChatService interface {
|
||||
CreateSession(ctx context.Context) (*OrchestratorChatSession, error)
|
||||
ExecuteStream(ctx context.Context, req OrchestratorExecuteRequest, callback OrchestratorStreamCallback) error
|
||||
GetMessages(ctx context.Context, sessionID string) ([]OrchestratorMessage, error)
|
||||
DeleteSession(ctx context.Context, sessionID string) error
|
||||
ListAvailableTools(ctx context.Context, prompt string) []string
|
||||
SetAutonomousMode(enabled bool)
|
||||
// ExecuteInvestigationStream runs one Patrol investigation under the
|
||||
// core-owned investigation execution profile and returns the
|
||||
// structured result. There is deliberately no generic execution
|
||||
// method, no tool listing outside the investigation projection, and
|
||||
// no autonomy field anywhere on this interface: non-interactive
|
||||
// operation grants no authority, and enterprise code can neither
|
||||
// select a profile nor relax one.
|
||||
ExecuteInvestigationStream(ctx context.Context, req OrchestratorInvestigationRequest, callback OrchestratorStreamCallback) (*OrchestratorInvestigationResult, error)
|
||||
// ListInvestigationTools names the tools an investigation run
|
||||
// offers, projected through the same profile path the run uses.
|
||||
ListInvestigationTools(ctx context.Context) []string
|
||||
}
|
||||
|
||||
// OrchestratorCommandExecutor executes commands directly (bypasses the LLM).
|
||||
type OrchestratorCommandExecutor interface {
|
||||
ExecuteCommand(ctx context.Context, command, targetHost string) (output string, exitCode int, err error)
|
||||
// OrchestratorInvestigationRequest is one investigation run. Correlation
|
||||
// identity is injected here by the orchestrator from trusted context; the
|
||||
// model's proposal tool schema never carries it.
|
||||
type OrchestratorInvestigationRequest struct {
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Prompt string `json:"prompt"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
MaxTurns int `json:"max_turns,omitempty"`
|
||||
ExecutionID string `json:"execution_id,omitempty"`
|
||||
ProposalID string `json:"proposal_id"`
|
||||
FindingID string `json:"finding_id"`
|
||||
InvestigationID string `json:"investigation_id"`
|
||||
EvidenceIDs []string `json:"evidence_ids,omitempty"`
|
||||
}
|
||||
|
||||
// OrchestratorInvestigationResult is the structured outcome of one
|
||||
// investigation run. Proposal cardinality is first-class: consumers never
|
||||
// reconstruct proposals from session messages. A non-nil Proposal is
|
||||
// immutable, canonically valid, fully correlated, exposure-safe, and
|
||||
// produced only by a completely successful run - ready for
|
||||
// OrchestratorActionBroker.Submit.
|
||||
type OrchestratorInvestigationResult struct {
|
||||
Content string `json:"content"`
|
||||
Proposal *ActionProposal `json:"proposal,omitempty"`
|
||||
FailedProposalAttempts int `json:"failed_proposal_attempts,omitempty"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// Typed investigation proposal errors surfaced across the contract
|
||||
// boundary. Any of them means the run produced no actionable proposal.
|
||||
var (
|
||||
ErrInvestigationProposalAmbiguous = errors.New("ambiguous investigation result: multiple distinct action proposals were submitted")
|
||||
ErrInvestigationProposalIntegrity = errors.New("proposal integrity violation: one tool-use id submitted conflicting payloads")
|
||||
ErrInvestigationProposalAttemptsFailed = errors.New("investigation made proposal attempts but none validated")
|
||||
)
|
||||
|
||||
// OrchestratorFindingsStore provides access to patrol findings for the orchestrator.
|
||||
type OrchestratorFindingsStore interface {
|
||||
Get(id string) *Finding
|
||||
Update(f *Finding) bool
|
||||
}
|
||||
|
||||
// OrchestratorApprovalStore queues fixes for human approval.
|
||||
type OrchestratorApprovalStore interface {
|
||||
Create(approval *OrchestratorApproval) error
|
||||
}
|
||||
|
||||
// OrchestratorInfraContextProvider provides discovered infrastructure context.
|
||||
type OrchestratorInfraContextProvider interface {
|
||||
GetInfrastructureContext() string
|
||||
}
|
||||
|
||||
// OrchestratorAutonomyProvider provides the current autonomy level.
|
||||
type OrchestratorAutonomyProvider interface {
|
||||
GetCurrentAutonomyLevel() string
|
||||
IsFullModeUnlocked() bool
|
||||
}
|
||||
|
||||
// OrchestratorFixVerifier verifies that a fix resolved the issue.
|
||||
type OrchestratorFixVerifier interface {
|
||||
VerifyFixResolved(ctx context.Context, finding *Finding) (bool, error)
|
||||
}
|
||||
|
||||
// OrchestratorLicenseChecker provides license feature checking.
|
||||
type OrchestratorLicenseChecker interface {
|
||||
HasFeature(feature string) bool
|
||||
}
|
||||
|
||||
// OrchestratorMetricsCallback receives metrics events from the orchestrator.
|
||||
type OrchestratorMetricsCallback interface {
|
||||
RecordInvestigationOutcome(outcome string)
|
||||
@@ -78,14 +98,6 @@ type OrchestratorChatSession struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// OrchestratorExecuteRequest represents a chat execution request.
|
||||
type OrchestratorExecuteRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
MaxTurns int `json:"max_turns,omitempty"`
|
||||
AutonomousMode *bool `json:"autonomous_mode,omitempty"`
|
||||
}
|
||||
|
||||
// OrchestratorStreamCallback is called for each streaming event.
|
||||
type OrchestratorStreamCallback func(event OrchestratorStreamEvent)
|
||||
|
||||
@@ -240,23 +252,16 @@ type OrchestratorAIFindingsStore interface {
|
||||
|
||||
// OrchestratorDeps contains all dependencies for constructing an investigation orchestrator.
|
||||
type OrchestratorDeps struct {
|
||||
ChatService OrchestratorChatService
|
||||
// CmdExecutor and ApprovalStore are the legacy command-execution side
|
||||
// doors, retained only until the typed ActionBroker migration lands.
|
||||
// New orchestrator code must propose through ActionBroker and never
|
||||
// dispatch command text or create command-shaped approvals.
|
||||
CmdExecutor OrchestratorCommandExecutor
|
||||
ChatService OrchestratorChatService
|
||||
Store InvestigationStore
|
||||
FindingsStore OrchestratorFindingsStore
|
||||
ApprovalStore OrchestratorApprovalStore // may be nil
|
||||
// ActionBroker is the typed, plan-only proposal seam into the core
|
||||
// action lifecycle. Tenant-bound and actor-stamped by the core
|
||||
// adapter; may be nil until wiring lands.
|
||||
// action lifecycle - the ONLY route from an investigation to an
|
||||
// infrastructure mutation. Tenant-bound and actor-stamped by the
|
||||
// core adapter. REQUIRED: a missing broker disables the
|
||||
// orchestrator; there is no command-execution fallback.
|
||||
ActionBroker OrchestratorActionBroker
|
||||
Config InvestigationConfig
|
||||
InfraContext OrchestratorInfraContextProvider // may be nil
|
||||
Autonomy OrchestratorAutonomyProvider
|
||||
FixVerifier OrchestratorFixVerifier
|
||||
License OrchestratorLicenseChecker
|
||||
Metrics OrchestratorMetricsCallback
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user