Carry safe action metadata into Patrol handoffs

This commit is contained in:
rcourtman
2026-05-07 09:57:56 +01:00
parent b95bcd7f6e
commit 2763ff7d91
10 changed files with 264 additions and 85 deletions
@@ -320,8 +320,9 @@ the canonical monitored-system blocked payload.
governed action posture without raw command text. Structured handoff action
references may use the current live Patrol investigation-fix approval for
the finding when that approval is newer than the approval ID on the durable
record, but the payload may carry only IDs, status/risk/target metadata, and
fix/action references, never the approval command payload. Patrol
record, but the payload may carry only IDs, status/risk/target metadata,
safe generated approval summaries, command counts, and fix/action
references, never the approval command payload. Patrol
remediation-plan handoffs must use the same boundary for frontend-authored
prompts: plan status, risk, step labels, and command counts are allowed,
while raw command and rollback command payloads remain in governed action
@@ -341,7 +342,11 @@ the canonical monitored-system blocked payload.
recovery handoffs may use a still-available structured proposed-fix payload
only as safe metadata: description, target, risk, rationale, destructive
posture, and command count may enter the briefing, while raw command text
remains owned by governed remediation or approval surfaces. Direct
remains owned by governed remediation or approval surfaces. If the unified
finding list lacks a full investigation record, frontend finding-discussion
handoffs may hydrate the latest investigation session for the same safe
proposed-fix metadata, but they must not paste raw proposed-fix command text
into the authored prompt or visible briefing. Direct
alert-investigation API handoffs through `internal/api/ai_handlers.go` must
enforce that same request-scoped boundary by setting
`ai.ExecuteRequest.AutonomousMode` to
@@ -453,8 +458,8 @@ the canonical monitored-system blocked payload.
and that same Patrol investigation-context owner, so visible Assistant
drawer handoffs may include live pending-approval metadata only as safe
operator context: approval ID, status, risk, requested/expiry timestamps,
and target label are allowed, while approval command payloads stay inside
governed approval/remediation surfaces
target label, generated approval summary, and command count are allowed, while
approval command payloads stay inside governed approval/remediation surfaces
and the dedicated `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` owner, so recent-change counts and governed policy-posture fallbacks normalize once at the shared store boundary instead of as Patrol-hook-local payload repair
and the shared `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` card, so learned correlations and correlation context stay rendered through one governed frontend card instead of separate page-local list loops
and the same shared correlation card's ordering and truncation rule, so callers pass raw correlations instead of encoding their own top-N sort behavior
@@ -840,9 +840,13 @@ frontend primitive boundary.
yet, rather than opening the shared drawer as empty generic chat.
When the feature helper adds live approval state to the generic drawer
briefing, it may pass only safe approval metadata into
`AIChatContextBriefing`; raw approval commands remain owned by the governed
approval/remediation panels. The shared approval-required drawer banner must
derive its subject from that briefing or structured finding context, so
`AIChatContextBriefing`, including generated approval summaries and command
counts when available; raw approval commands remain owned by the governed
approval/remediation panels. If the generic finding-level helper hydrates
latest investigation detail to recover proposed-fix context, it may pass only
safe summary fields and command counts into the drawer briefing. The shared
approval-required drawer banner must derive its subject from that briefing or
structured finding context, so
Patrol handoffs render as Patrol handoffs or Patrol findings, and alert
handoffs render as alert investigations, rather than generic dashboard
briefs. Patrol approval-row Assistant prompts must
@@ -156,14 +156,16 @@ Patrol-specific presentation helpers.
generic empty Assistant drawer. When a live pending Patrol approval exists
for that finding, the visible Assistant briefing may include only safe
approval metadata such as approval ID, pending status, risk, requested time,
expiry, and target label; it must not copy the approval command payload into
Assistant drawer prose. The model-only runtime briefing must apply that same
expiry, target label, generated approval summary, and command count; it must
not copy the approval command payload into Assistant drawer prose. The
model-only runtime briefing must apply that same
recovered approval reference when framing the operator decision and action
posture. Inline Patrol approval actions that open Assistant must follow the
same rule: pass approval ID/status/risk/target as review context, force the
request-local approval-required mode, attach the Patrol-owned visible drawer
briefing for the pending approval, and never paste the approval command or
proposed-fix command text into the chat prompt. Remediation-plan Assistant
same rule: pass approval ID/status/risk/target plus safe summary/count
metadata as review context, force the request-local approval-required mode,
attach the Patrol-owned visible drawer briefing for the pending approval, and
never paste the approval command or proposed-fix command text into the chat
prompt. Remediation-plan Assistant
handoffs follow the same boundary: step labels, plan status, risk, and command
counts are allowed, safe suggested prompts may ask about plan risk,
prerequisites, rollback, and verification, while command and rollback command
@@ -187,6 +189,12 @@ Patrol-specific presentation helpers.
and command count. Raw command text remains in the governed remediation or
approval panel, while Assistant gets enough context to explain approval
recovery and risk without becoming an execution surface.
Generic finding-level Assistant handoffs must use that same safe metadata
boundary when the list response lacks a full investigation record: they may
hydrate the latest investigation session to recover proposed-fix summary,
risk, target, rationale, destructive posture, and command count, but they must
still keep command text out of both the user-authored prompt and visible
Assistant briefing.
If the referenced finding is no longer current, Assistant must drop the
stored handoff instead of continuing from stale Patrol context. Assistant
handoff context must also carry the unified
@@ -20,13 +20,16 @@ import { aiChatStore } from '@/stores/aiChat';
import {
buildPatrolAssistantFindingBriefing,
buildPatrolAssistantFindingPrompt,
buildPatrolAssistantProposedFixBriefingInput,
buildPatrolRemediationPlanAssistantBriefing,
buildPatrolRemediationPlanAssistantPrompt,
patrolAssistantFindingHandoffRequiresApprovalMode,
type PatrolAssistantApprovalBriefingInput,
type PatrolAssistantProposedFixBriefingInput,
} from '@/features/patrol/patrolInvestigationContextModel';
import { useResources } from '@/hooks/useResources';
import { InvestigationSection, ApprovalSection } from '@/components/patrol';
import type { RemediationPlan } from '@/api/ai';
import { AIAPI, type ApprovalRequest, type RemediationPlan } from '@/api/ai';
import type { PatrolRunRecord, PatrolRuntimeState } from '@/api/patrol';
import { buildResolvedResourceSurfaceLinks } from '@/routing/resourceLinks';
import { getApprovalRiskPresentation } from '@/utils/approvalRiskPresentation';
@@ -420,6 +423,55 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
setEditingNoteId(null);
};
const buildLiveApprovalProposedFixBriefing = (
approval: ApprovalRequest | undefined,
): PatrolAssistantProposedFixBriefingInput | undefined =>
buildPatrolAssistantProposedFixBriefingInput(
approval
? {
description: approval.context,
riskLevel: approval.riskLevel,
targetHost: approval.targetName,
commandCount: approval.command ? 1 : 0,
}
: null,
);
const loadLatestInvestigationProposedFixBriefing = async (
finding: UnifiedFinding,
pendingApprovalBriefing: PatrolAssistantApprovalBriefingInput | undefined,
): Promise<PatrolAssistantProposedFixBriefingInput | undefined> => {
if (finding.investigationRecord?.proposed_fix) {
return undefined;
}
const hasInvestigationPointer = Boolean(
finding.investigationOutcome ||
finding.investigationSessionId ||
finding.lastInvestigatedAt ||
pendingApprovalBriefing?.id,
);
if (!hasInvestigationPointer) {
return undefined;
}
if (
!patrolAssistantFindingHandoffRequiresApprovalMode({
investigationOutcome: finding.investigationOutcome,
remediationId: finding.remediationPlanId,
pendingApproval: pendingApprovalBriefing,
investigationRecord: finding.investigationRecord,
})
) {
return undefined;
}
try {
const investigation = await AIAPI.getInvestigation(finding.id);
return buildPatrolAssistantProposedFixBriefingInput(investigation?.proposed_fix);
} catch {
return undefined;
}
};
const handleDiscussWithAssistant = async (finding: UnifiedFinding, e: Event) => {
e.stopPropagation();
await aiIntelligenceStore.loadPendingApprovals();
@@ -438,6 +490,12 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
targetName: pendingApproval.targetName,
}
: undefined;
const latestInvestigationProposedFix = await loadLatestInvestigationProposedFixBriefing(
finding,
pendingApprovalBriefing,
);
const proposedFix =
latestInvestigationProposedFix || buildLiveApprovalProposedFixBriefing(pendingApproval);
const prompt = buildPatrolAssistantFindingPrompt({
title,
subject,
@@ -456,6 +514,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
lastRegressionAt: finding.lastRegressionAt,
remediationId: finding.remediationPlanId,
pendingApproval: pendingApprovalBriefing,
proposedFix,
investigationRecord: finding.investigationRecord,
});
const requiresApprovalMode = patrolAssistantFindingHandoffRequiresApprovalMode({
@@ -45,9 +45,12 @@ const patrolWorkspaceSource = readFileSync(
describe('FindingsPanel assistant handoff', () => {
it('routes Patrol investigation records into the Assistant briefing context', () => {
expect(findingsPanelSource).toContain('buildPatrolAssistantFindingBriefing');
expect(findingsPanelSource).toContain('buildPatrolAssistantProposedFixBriefingInput');
expect(findingsPanelSource).toContain('briefing,');
expect(findingsPanelSource).toContain('investigationRecord: finding.investigationRecord');
expect(findingsPanelSource).toContain('pendingApproval: pendingApproval');
expect(findingsPanelSource).toContain('proposedFix,');
expect(findingsPanelSource).toContain('AIAPI.getInvestigation(finding.id)');
expect(findingsPanelSource).toContain('await aiIntelligenceStore.loadPendingApprovals()');
expect(findingsPanelSource).toContain('patrolAssistantFindingHandoffRequiresApprovalMode');
expect(findingsPanelSource).toContain(
@@ -12,7 +12,11 @@ import { aiChatStore } from '@/stores/aiChat';
import { hasFeature } from '@/stores/license';
import { AIAPI, type ApprovalRequest, type ApprovalExecutionResult } from '@/api/ai';
import { getApprovalRiskPresentation } from '@/utils/approvalRiskPresentation';
import { buildPatrolAssistantFindingBriefing } from '@/features/patrol/patrolInvestigationContextModel';
import {
buildPatrolAssistantFindingBriefing,
buildPatrolAssistantProposedFixBriefingInput,
type PatrolAssistantProposedFixBriefingSource,
} from '@/features/patrol/patrolInvestigationContextModel';
import { RemediationStatus } from './RemediationStatus';
interface ApprovalSectionProps {
@@ -24,15 +28,6 @@ interface ApprovalSectionProps {
resourceId?: string;
}
interface AssistantBriefingFixSource {
description?: string | null;
commands?: string[] | null;
target_host?: string | null;
risk_level?: string | null;
rationale?: string | null;
destructive?: boolean | null;
}
export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
const [actionLoading, setActionLoading] = createSignal<string | null>(null);
const [executionResult, setExecutionResult] = createSignal<ApprovalExecutionResult | null>(null);
@@ -50,7 +45,7 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
const approvalBriefing = (
approval: ApprovalRequest | null,
fix?: AssistantBriefingFixSource | null,
fix?: PatrolAssistantProposedFixBriefingSource | null,
) =>
buildPatrolAssistantFindingBriefing({
title: props.findingTitle || 'Patrol finding',
@@ -68,21 +63,22 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
targetName: approval.targetName,
}
: null,
proposedFix: fix
? {
description: fix.description,
riskLevel: fix.risk_level,
targetHost: fix.target_host,
rationale: fix.rationale,
commandCount: fix.commands?.length ?? 0,
destructive: fix.destructive,
}
: null,
proposedFix: buildPatrolAssistantProposedFixBriefingInput(
fix ||
(approval
? {
description: approval.context,
riskLevel: approval.riskLevel,
targetHost: approval.targetName,
commandCount: approval.command ? 1 : 0,
}
: null),
),
});
const handleFixWithAssistant = (
approval: ApprovalRequest | null,
fix: AssistantBriefingFixSource | null,
fix: PatrolAssistantProposedFixBriefingSource | null,
e: Event,
) => {
e.stopPropagation();
@@ -197,10 +197,19 @@ describe('ApprovalSection', () => {
statusLabel: 'Pending approval · High risk',
detailLines: expect.arrayContaining([
expect.stringContaining('live approval pending'),
expect.stringContaining('Proposed fix: Restart the workload service'),
expect.stringContaining('1 command recorded for approval context'),
expect.stringContaining('Review live governed approval approval-1 before execution'),
]),
actionLabel: 'Approval approval-1',
safetyNote: 'Execution requires the governed approval flow.',
actionLabel: 'Restart the workload service',
commandSummary: '1 command recorded for approval context',
safetyNote:
'Command details stay in approval context; execution requires the governed approval flow.',
suggestedPrompts: [
'Review approval risk and next step',
'Explain current finding status',
'Summarize remediation without command text',
],
}),
autonomousMode: false,
});
@@ -5,6 +5,7 @@ import {
buildPatrolAssessmentAssistantHandoff,
buildPatrolAssistantFindingBriefing,
buildPatrolAssistantFindingPrompt,
buildPatrolAssistantProposedFixBriefingInput,
buildPatrolInvestigationContextSummary,
buildPatrolInvestigationRecordPresentation,
buildPatrolRemediationPlanAssistantBriefing,
@@ -322,6 +323,27 @@ describe('patrolInvestigationContextModel', () => {
expect(JSON.stringify(presentation)).not.toContain('systemctl restart workload.service');
});
it('normalizes safe proposed-fix briefing metadata without command text', () => {
const briefing = buildPatrolAssistantProposedFixBriefingInput({
description: 'Restart the workload service',
commands: ['systemctl restart workload.service'],
risk_level: 'high',
target_host: 'node-1',
rationale: 'Service stayed wedged after IO pressure.',
destructive: true,
});
expect(briefing).toEqual({
description: 'Restart the workload service',
riskLevel: 'high',
targetHost: 'node-1',
rationale: 'Service stayed wedged after IO pressure.',
commandCount: 1,
destructive: true,
});
expect(JSON.stringify(briefing)).not.toContain('systemctl restart workload.service');
});
it('frames Assistant handoff around the structured Patrol record when one exists', () => {
expect(
buildPatrolAssistantFindingPrompt({
@@ -77,6 +77,18 @@ export interface PatrolAssistantProposedFixBriefingInput {
destructive?: boolean | null;
}
export interface PatrolAssistantProposedFixBriefingSource {
description?: string | null;
riskLevel?: string | null;
risk_level?: string | null;
targetHost?: string | null;
target_host?: string | null;
rationale?: string | null;
commandCount?: number | null;
commands?: readonly string[] | null;
destructive?: boolean | null;
}
export interface PatrolAssistantFindingBriefingInput {
title: string;
subject: string;
@@ -225,16 +237,9 @@ export function buildPatrolInvestigationRecordPresentation(
};
}
const proposedFix = record.proposed_fix
? {
description: normalizeText(record.proposed_fix.description),
riskLabel: formatIdentifierLabel(record.proposed_fix.risk_level),
targetHost: normalizeText(record.proposed_fix.target_host),
rationale: normalizeText(record.proposed_fix.rationale),
commandSummary: formatCommandSummary(record.proposed_fix.commands?.length ?? 0),
destructive: Boolean(record.proposed_fix.destructive),
}
: undefined;
const proposedFix = normalizeProposedFixBriefing(
buildPatrolAssistantProposedFixBriefingInput(record.proposed_fix),
);
return {
hasRecord: true,
@@ -264,6 +269,39 @@ export function buildPatrolInvestigationRecordPresentation(
};
}
export function buildPatrolAssistantProposedFixBriefingInput(
source?: PatrolAssistantProposedFixBriefingSource | null,
): PatrolAssistantProposedFixBriefingInput | undefined {
if (!source) return undefined;
const commandCount =
typeof source.commandCount === 'number'
? source.commandCount
: Array.isArray(source.commands)
? source.commands.length
: null;
const briefing = {
description: normalizeText(source.description),
riskLevel: normalizeText(source.riskLevel || source.risk_level),
targetHost: normalizeText(source.targetHost || source.target_host),
rationale: normalizeText(source.rationale),
commandCount: normalizeNonNegativeCount(commandCount),
destructive: typeof source.destructive === 'boolean' ? source.destructive : null,
};
if (
!briefing.description &&
!briefing.riskLevel &&
!briefing.targetHost &&
!briefing.rationale &&
!briefing.commandCount &&
briefing.destructive !== true
) {
return undefined;
}
return briefing;
}
export function buildPatrolAssistantFindingPrompt(
input: PatrolAssistantFindingPromptInput,
): string {
@@ -1389,6 +1427,9 @@ function buildPatrolAssistantSafetyNote(
if (hasCommands && isDestructive) {
return 'Command details stay in approval context; destructive actions require governed approval.';
}
if (hasCommands && pendingApproval?.id) {
return 'Command details stay in approval context; execution requires the governed approval flow.';
}
if (hasCommands) {
return 'Command details stay in approval context.';
}
@@ -48,6 +48,7 @@ test.describe("Patrol Assistant operator briefing", () => {
const approvalRequestedAt = new Date(Date.now() - 60_000).toISOString();
const approvalExpiresAt = new Date(Date.now() + 10 * 60_000).toISOString();
let includePendingApproval = true;
let includeUnifiedInvestigationRecord = true;
let includeInvestigationProposedFix = false;
await page.route("**/api/security/status", async (route) => {
@@ -262,42 +263,46 @@ test.describe("Patrol Assistant operator briefing", () => {
investigation_status: "completed",
investigation_outcome: "fix_queued",
investigation_attempts: 1,
investigation_record: {
id: "record-1",
finding_id: "finding-operator-briefing",
subject: {
resource_id: "host:web-server",
resource_name: "web-server",
resource_type: "host",
},
trigger: {
detected_at: "2026-05-06T12:00:00Z",
title: "High CPU usage",
},
status: "completed",
outcome: "fix_queued",
confidence: "high",
conclusion: "Backup job saturated CPU.",
recommended_action:
"Approve a controlled restart after the backup completes.",
evidence: [
{
kind: "metrics",
summary: "CPU stayed above 95% for 10 minutes",
},
],
proposed_fix: {
id: "fix-1",
description: "Restart the workload service",
commands: ["systemctl restart workload.service"],
risk_level: "medium",
destructive: true,
},
verification: ["CPU returned below 50%"],
tools_used: [],
started_at: "2026-05-06T12:00:00Z",
approval_id: "approval-1",
},
...(includeUnifiedInvestigationRecord
? {
investigation_record: {
id: "record-1",
finding_id: "finding-operator-briefing",
subject: {
resource_id: "host:web-server",
resource_name: "web-server",
resource_type: "host",
},
trigger: {
detected_at: "2026-05-06T12:00:00Z",
title: "High CPU usage",
},
status: "completed",
outcome: "fix_queued",
confidence: "high",
conclusion: "Backup job saturated CPU.",
recommended_action:
"Approve a controlled restart after the backup completes.",
evidence: [
{
kind: "metrics",
summary: "CPU stayed above 95% for 10 minutes",
},
],
proposed_fix: {
id: "fix-1",
description: "Restart the workload service",
commands: ["systemctl restart workload.service"],
risk_level: "medium",
destructive: true,
},
verification: ["CPU returned below 50%"],
tools_used: [],
started_at: "2026-05-06T12:00:00Z",
approval_id: "approval-1",
},
}
: {}),
},
],
count: 1,
@@ -510,6 +515,7 @@ test.describe("Patrol Assistant operator briefing", () => {
queuedAssistantContext.getByText("systemctl restart workload.service"),
).toHaveCount(0);
includeUnifiedInvestigationRecord = false;
includeInvestigationProposedFix = true;
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.getByRole("button", { name: "Findings" })).toBeVisible();
@@ -517,6 +523,32 @@ test.describe("Patrol Assistant operator briefing", () => {
await page.getByText("High CPU usage").click();
const expiredFinding = page.locator("#finding-finding-operator-briefing");
await expect(expiredFinding.getByText("approval expired")).toBeVisible();
await expiredFinding
.getByRole("button", { name: "Discuss with Assistant" })
.first()
.click();
const hydratedFindingAssistantContext =
page.getByLabel("Assistant context");
await expect(hydratedFindingAssistantContext).toBeVisible();
await expect(hydratedFindingAssistantContext).toContainText(
"Operator briefing attached",
);
await expect(hydratedFindingAssistantContext).toContainText("Fix Queued");
await expect(hydratedFindingAssistantContext).toContainText(
"Proposed fix: Restart the workload service; target web-server; high risk; 1 command recorded for approval context; destructive proposed fix; rationale Workload service stayed wedged after backup pressure.",
);
await expect(
hydratedFindingAssistantContext.getByRole("button", {
name: "Summarize remediation without command text",
}),
).toBeVisible();
await expect(
hydratedFindingAssistantContext.getByText(
"systemctl restart workload.service",
),
).toHaveCount(0);
await expiredFinding
.getByRole("button", { name: "Fix with Assistant" })
.last()