Route queued fix recovery through Patrol briefing

This commit is contained in:
rcourtman 2026-05-07 09:39:43 +01:00
parent 1d70402f71
commit 1cbedbd192
9 changed files with 154 additions and 23 deletions

View file

@ -332,9 +332,15 @@ the canonical monitored-system blocked payload.
briefing payloads may include short suggested prompts, but those prompts
must be derived from the same safe action posture, evidence, recurrence, and
remediation-plan metadata as the briefing and must never carry raw approval,
command, or rollback command text. Direct alert-investigation API
handoffs through `internal/api/ai_handlers.go` must enforce that same
request-scoped boundary by setting `ai.ExecuteRequest.AutonomousMode` to
command, or rollback command text. Frontend queued-fix recovery handoffs
where the live approval or proposed-fix payload is unavailable must still
carry that Patrol-owned operator briefing, current `fix_queued` posture,
request-local approval-required mode, and safe suggested prompts; they must
not degrade into generic Assistant investigation chat or imply that
execution can proceed from missing command payloads. Direct
alert-investigation API handoffs through `internal/api/ai_handlers.go` must
enforce that same request-scoped boundary by setting
`ai.ExecuteRequest.AutonomousMode` to
false and `ai.ExecuteRequest.RequireCommandApproval` to true; API proof must
keep this guarded in both `internal/api/ai_handlers_test.go` and
`internal/api/contract_test.go`. The operator

View file

@ -824,7 +824,10 @@ frontend primitive boundary.
suggested prompts must be derived from safe briefing metadata such as
evidence, confidence, approval risk, prerequisites, recurrence, rollback,
and verification posture; they must not become another primitive path for
raw approval, command, or rollback command payload text.
raw approval, command, or rollback command payload text. Missing-detail
queued-fix recovery actions must still provide the feature-owned Patrol
briefing and request-local approval-required posture rather than opening the
shared drawer as context-free generic Assistant chat.
When those feature-owned helpers attach backend model-only context, the
drawer store may carry only bounded handoff text and structured resource
references for the shared chat transport; approval, lifecycle, and command

View file

@ -176,6 +176,11 @@ Patrol-specific presentation helpers.
injection, so Patrol-owned prose
cannot leak governed resource names, IDs, aliases, nodes, paths, or
addresses outside the canonical policy boundary.
If a `fix_queued` finding no longer has a live approval payload or detailed
proposed-fix payload available, the recovery action must still open Assistant
with the same Patrol-owned visible briefing and approval-required posture
from current finding facts; it must not fall back to generic investigation
chat or invite execution from missing command details.
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

View file

@ -449,6 +449,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
subject,
severity: finding.severity,
findingStatus: finding.status,
investigationOutcome: finding.investigationOutcome,
loopState: finding.loopState,
timesRaised: finding.timesRaised,
regressionCount: finding.regressionCount,

View file

@ -44,6 +44,7 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
title: props.findingTitle || 'Patrol finding',
subject: props.resourceName || 'affected resource',
findingStatus: 'active',
investigationOutcome: props.investigationOutcome,
loopState: props.investigationOutcome || 'awaiting_approval',
pendingApproval: approval
? {
@ -94,12 +95,19 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
const handleDiscussQueuedFix = (e: Event) => {
e.stopPropagation();
const title = props.findingTitle || 'Patrol finding';
const subject = props.resourceName || 'affected resource';
aiChatStore.openWithPrompt(
`Patrol queued a fix for a finding, but the original approval details are no longer available.\n\n**Finding:** ${props.findingTitle || 'Unknown finding'} on ${props.resourceName || 'unknown resource'}\n\nPlease help me investigate the issue again and propose a safe remediation approach.`,
[
`Patrol queued a governed fix for ${title} on ${subject}, but the live approval payload is no longer available.`,
'Use the attached Patrol briefing to explain the current action state, approval recovery path, and safest next step.',
'Do not infer, repeat, or execute raw command text from this chat handoff.',
].join('\n\n'),
{
targetType: props.resourceType,
targetId: props.resourceId,
findingId: props.findingId,
briefing: approvalBriefing(null),
autonomousMode: false,
},
);

View file

@ -123,11 +123,26 @@ describe('ApprovalSection', () => {
fireEvent.click(screen.getByRole('button', { name: /discuss with assistant/i }));
expect(openWithPromptMock).toHaveBeenCalledWith(
expect.stringContaining('Patrol queued a fix for a finding'),
expect.stringContaining('Patrol queued a governed fix for CPU saturation on node-1'),
{
targetType: 'host',
targetId: 'host-1',
findingId: 'finding-1',
briefing: expect.objectContaining({
sourceLabel: 'Pulse Patrol',
title: 'Operator briefing attached',
subject: 'CPU saturation on node-1',
statusLabel: 'Fix Queued',
detailLines: expect.arrayContaining([
expect.stringContaining('fix queued for governed review'),
expect.stringContaining('Recover or regenerate the governed approval before execution'),
]),
suggestedPrompts: [
'Review approval risk and next step',
'Explain current finding status',
'List approval prerequisites before action',
],
}),
autonomousMode: false,
},
);

View file

@ -584,4 +584,34 @@ describe('patrolInvestigationContextModel', () => {
],
});
});
it('builds a queued-fix recovery briefing when live approval details are unavailable', () => {
expect(
buildPatrolAssistantFindingBriefing({
title: 'CPU saturation',
subject: 'node-1',
findingStatus: 'active',
investigationOutcome: 'fix_queued',
loopState: 'fix_queued',
}),
).toEqual({
sourceLabel: 'Pulse Patrol',
title: 'Operator briefing attached',
subject: 'CPU saturation on node-1',
statusLabel: 'Fix Queued',
detailLines: [
'Attention: active finding; loop fix queued; fix queued for governed review',
'Decision: Recover or regenerate the governed approval before execution; do not execute from chat context.',
],
evidence: [],
actionLabel: undefined,
commandSummary: undefined,
safetyNote: undefined,
suggestedPrompts: [
'Review approval risk and next step',
'Explain current finding status',
'List approval prerequisites before action',
],
});
});
});

View file

@ -73,6 +73,7 @@ export interface PatrolAssistantFindingBriefingInput {
subject: string;
severity?: string | null;
findingStatus?: string | null;
investigationOutcome?: string | null;
loopState?: string | null;
timesRaised?: number | null;
regressionCount?: number | null;
@ -1024,6 +1025,7 @@ export function buildPatrolAssistantFindingBriefing(
? [
pendingApproval.status ? `${formatIdentifierLabel(pendingApproval.status)} approval` : '',
pendingApproval.riskLevel ? `${formatIdentifierLabel(pendingApproval.riskLevel)} risk` : '',
!pendingApproval.id ? formatIdentifierLabel(input.investigationOutcome) || '' : '',
]
: [];
const statusParts = [
@ -1097,7 +1099,7 @@ function buildPatrolFindingSuggestedPrompts(
): string[] {
const prompts: string[] = [];
const requiresApproval = patrolAssistantFindingHandoffRequiresApprovalMode({
investigationOutcome: input.investigationRecord?.outcome,
investigationOutcome: input.investigationOutcome || input.investigationRecord?.outcome,
remediationId: input.remediationId,
pendingApproval,
investigationRecord: input.investigationRecord,
@ -1198,7 +1200,7 @@ function buildPatrolAssistantAttentionReason(
parts.push('destructive proposed fix');
}
switch (normalizeText(rawRecord?.outcome)) {
switch (normalizeText(rawRecord?.outcome || input.investigationOutcome)) {
case 'fix_queued':
parts.push('fix queued for governed review');
break;
@ -1327,6 +1329,23 @@ function buildPatrolAssistantOperatorDecision(
return `Review governed remediation ${remediationId} before execution.`;
}
switch (normalizeText(input.investigationOutcome).toLowerCase()) {
case 'fix_queued':
return 'Recover or regenerate the governed approval before execution; do not execute from chat context.';
case 'fix_executed':
return 'Verify the execution result before closing or resolving the finding.';
case 'fix_failed':
case 'fix_verification_failed':
return 'Review failed remediation evidence before retrying or escalating.';
case 'fix_verification_unknown':
return 'Gather verification evidence before closing or retrying remediation.';
case 'needs_attention':
case 'cannot_fix':
return 'Operator intervention is required; use the evidence to choose the next manual step.';
case 'timed_out':
return 'Patrol timed out; rerun investigation or gather more evidence before remediation.';
}
const loopState = normalizeText(input.loopState).toLowerCase();
if (loopState.includes('approval')) {
return 'Review the governed approval flow before execution.';

View file

@ -47,6 +47,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;
await page.route("**/api/security/status", async (route) => {
await route.fulfill({
@ -366,25 +367,35 @@ test.describe("Patrol Assistant operator briefing", () => {
status: 200,
contentType: "application/json",
body: JSON.stringify({
approvals: [
{
id: "approval-1",
toolId: "investigation_fix",
command: "systemctl restart workload.service",
targetType: "host",
targetId: "finding-operator-briefing",
targetName: "web-server",
context: "Restart the workload service",
riskLevel: "high",
status: "pending",
requestedAt: approvalRequestedAt,
expiresAt: approvalExpiresAt,
},
],
approvals: includePendingApproval
? [
{
id: "approval-1",
toolId: "investigation_fix",
command: "systemctl restart workload.service",
targetType: "host",
targetId: "finding-operator-briefing",
targetName: "web-server",
context: "Restart the workload service",
riskLevel: "high",
status: "pending",
requestedAt: approvalRequestedAt,
expiresAt: approvalExpiresAt,
},
]
: [],
}),
});
});
await page.route("**/api/ai/findings/*/investigation", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(null),
});
});
await page.route("**/api/ai/remediation/plans", async (route) => {
await route.fulfill({
status: 200,
@ -442,5 +453,38 @@ test.describe("Patrol Assistant operator briefing", () => {
).toHaveValue("Review approval risk and next step");
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
includePendingApproval = false;
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.getByRole("button", { name: "Findings" })).toBeVisible();
await page.getByText("High CPU usage").click();
const queuedFinding = page.locator("#finding-finding-operator-briefing");
await expect(queuedFinding.getByText("details unavailable")).toBeVisible();
await queuedFinding
.getByRole("button", { name: "Discuss with Assistant" })
.last()
.click();
const queuedAssistantContext = page.getByLabel("Assistant context");
await expect(queuedAssistantContext).toBeVisible();
await expect(queuedAssistantContext).toContainText(
"Operator briefing attached",
);
await expect(queuedAssistantContext).toContainText("Fix Queued");
await expect(queuedAssistantContext).toContainText(
"Attention: active finding; loop fix queued; fix queued for governed review",
);
await expect(queuedAssistantContext).toContainText(
"Decision: Recover or regenerate the governed approval before execution; do not execute from chat context.",
);
await expect(
queuedAssistantContext.getByRole("button", {
name: "List approval prerequisites before action",
}),
).toBeVisible();
await expect(
queuedAssistantContext.getByText("systemctl restart workload.service"),
).toHaveCount(0);
});
});