Require approval mode for governed Patrol finding handoffs

This commit is contained in:
rcourtman
2026-05-07 01:04:25 +01:00
parent bebe9ca85b
commit b7df44e6ce
7 changed files with 114 additions and 14 deletions
@@ -324,7 +324,10 @@ the canonical monitored-system blocked payload.
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
surfaces. The operator
surfaces. Frontend finding-discussion handoffs that carry any live approval,
proposed-fix, fix outcome, or remediation-plan reference must force a
request-local approval-required Assistant mode instead of inheriting the
user's persistent autonomous control setting. The operator
decision and action-posture lines in the briefing must derive from those
same structured action references after recovery so the briefing cannot
contradict the handoff action payload. Related finding
@@ -826,7 +826,11 @@ frontend primitive boundary.
persistent Assistant control level. Patrol remediation-plan drawer handoffs
must use the same primitive boundary: plan title/status/risk, step labels,
and command counts may enter Assistant context; raw command and rollback
command payloads must stay in the governed remediation/action panel.
command payloads must stay in the governed remediation/action panel. Finding
discussion handoffs that reference a live approval, proposed fix, fix
outcome, or remediation plan must also pass `autonomousMode:false` as a
request-local override so the drawer shows approval-required posture without
mutating the persistent Assistant control setting.
11. Keep shared filter primitives coherent with route-owned option hydration.
Feature shells such as `frontend-modern/src/features/infrastructure/`
must keep a route-owned canonical option visible in shared selects like
@@ -163,8 +163,12 @@ Patrol-specific presentation helpers.
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, while command and rollback command text stays in the
governed remediation or approval surface. The assembled handoff must still
pass through the Assistant runtime's
governed remediation or approval surface. Generic finding discussion
handoffs must also force request-local approval-required mode when the finding
already references a live approval, proposed fix, fix outcome, or remediation
plan, so default autonomous Assistant settings cannot bypass the Patrol
action-governance boundary. The assembled handoff must still pass through the
Assistant runtime's
resource-policy sanitizer before prompt injection, so Patrol-owned prose
cannot leak governed resource names, IDs, aliases, nodes, paths, or
addresses outside the canonical policy boundary.
@@ -22,6 +22,7 @@ import {
buildPatrolAssistantFindingPrompt,
buildPatrolRemediationPlanAssistantBriefing,
buildPatrolRemediationPlanAssistantPrompt,
patrolAssistantFindingHandoffRequiresApprovalMode,
} from '@/features/patrol/patrolInvestigationContextModel';
import { useResources } from '@/hooks/useResources';
import { InvestigationSection, ApprovalSection } from '@/components/patrol';
@@ -427,6 +428,16 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
const pendingApproval = aiIntelligenceStore.patrolPendingApprovals.find(
(approval) => approval.toolId === 'investigation_fix' && approval.targetId === finding.id,
);
const pendingApprovalBriefing = pendingApproval
? {
id: pendingApproval.id,
status: pendingApproval.status,
riskLevel: pendingApproval.riskLevel,
requestedAt: pendingApproval.requestedAt,
expiresAt: pendingApproval.expiresAt,
targetName: pendingApproval.targetName,
}
: undefined;
const prompt = buildPatrolAssistantFindingPrompt({
title,
subject,
@@ -443,16 +454,13 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
regressionCount: finding.regressionCount,
lastRegressionAt: finding.lastRegressionAt,
remediationId: finding.remediationPlanId,
pendingApproval: pendingApproval
? {
id: pendingApproval.id,
status: pendingApproval.status,
riskLevel: pendingApproval.riskLevel,
requestedAt: pendingApproval.requestedAt,
expiresAt: pendingApproval.expiresAt,
targetName: pendingApproval.targetName,
}
: undefined,
pendingApproval: pendingApprovalBriefing,
investigationRecord: finding.investigationRecord,
});
const requiresApprovalMode = patrolAssistantFindingHandoffRequiresApprovalMode({
investigationOutcome: finding.investigationOutcome,
remediationId: finding.remediationPlanId,
pendingApproval: pendingApprovalBriefing,
investigationRecord: finding.investigationRecord,
});
aiChatStore.openWithPrompt(prompt, {
@@ -460,6 +468,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
targetId: finding.resourceId,
findingId: finding.id,
briefing,
autonomousMode: requiresApprovalMode ? false : undefined,
});
};
@@ -49,6 +49,10 @@ describe('FindingsPanel assistant handoff', () => {
expect(findingsPanelSource).toContain('investigationRecord: finding.investigationRecord');
expect(findingsPanelSource).toContain('pendingApproval: pendingApproval');
expect(findingsPanelSource).toContain('await aiIntelligenceStore.loadPendingApprovals()');
expect(findingsPanelSource).toContain('patrolAssistantFindingHandoffRequiresApprovalMode');
expect(findingsPanelSource).toContain(
'autonomousMode: requiresApprovalMode ? false : undefined',
);
});
it('routes remediation plan handoffs through the command-free Patrol handoff model', () => {
@@ -8,6 +8,7 @@ import {
buildPatrolInvestigationRecordPresentation,
buildPatrolRemediationPlanAssistantBriefing,
buildPatrolRemediationPlanAssistantPrompt,
patrolAssistantFindingHandoffRequiresApprovalMode,
} from '../patrolInvestigationContextModel';
describe('patrolInvestigationContextModel', () => {
@@ -280,6 +281,50 @@ describe('patrolInvestigationContextModel', () => {
expect(JSON.stringify(briefing)).not.toContain('systemctl');
});
it('forces approval-required Assistant mode for governed finding handoffs', () => {
expect(
patrolAssistantFindingHandoffRequiresApprovalMode({
pendingApproval: { id: 'approval-1', status: 'pending' },
}),
).toBe(true);
expect(
patrolAssistantFindingHandoffRequiresApprovalMode({
remediationId: 'plan-1',
}),
).toBe(true);
expect(
patrolAssistantFindingHandoffRequiresApprovalMode({
investigationOutcome: 'fix_queued',
}),
).toBe(true);
expect(
patrolAssistantFindingHandoffRequiresApprovalMode({
investigationRecord: {
id: 'record-1',
finding_id: 'finding-1',
subject: { resource_id: 'agent-1' },
trigger: { detected_at: '2026-05-06T12:00:00Z' },
status: 'completed',
evidence: [],
proposed_fix: {
id: 'fix-1',
description: 'Restart service',
commands: ['systemctl restart nginx'],
destructive: false,
},
verification: [],
tools_used: [],
started_at: '2026-05-06T12:00:00Z',
},
}),
).toBe(true);
expect(
patrolAssistantFindingHandoffRequiresApprovalMode({
investigationOutcome: 'needs_attention',
}),
).toBe(false);
});
it('builds an operator briefing from current finding facts before a Patrol record exists', () => {
expect(
buildPatrolAssistantFindingBriefing({
@@ -70,6 +70,13 @@ export interface PatrolAssistantFindingBriefingInput {
investigationRecord?: InvestigationRecord | null;
}
export interface PatrolAssistantFindingModeInput {
investigationOutcome?: string | null;
remediationId?: string | null;
pendingApproval?: PatrolAssistantApprovalBriefingInput | null;
investigationRecord?: InvestigationRecord | null;
}
export interface PatrolRemediationPlanAssistantInput {
title: string;
subject: string;
@@ -176,6 +183,21 @@ export function buildPatrolAssistantFindingPrompt(
return prompt;
}
export function patrolAssistantFindingHandoffRequiresApprovalMode(
input: PatrolAssistantFindingModeInput,
): boolean {
const pendingApproval = normalizeApprovalBriefing(input.pendingApproval);
if (pendingApproval.id) return true;
if (normalizeText(input.remediationId)) return true;
const record = input.investigationRecord;
if (normalizeText(record?.approval_id)) return true;
if (record?.proposed_fix) return true;
const outcome = normalizeText(input.investigationOutcome || record?.outcome).toLowerCase();
return GOVERNED_ACTION_OUTCOMES.has(outcome);
}
export function buildPatrolRemediationPlanAssistantPrompt(
input: PatrolRemediationPlanAssistantInput,
): string {
@@ -629,3 +651,12 @@ const PATROL_TOOL_LABELS: Record<string, string> = {
'metrics.history': 'Metrics history',
'ssh.exec': 'SSH exec',
};
const GOVERNED_ACTION_OUTCOMES = new Set([
'fix_queued',
'fix_executed',
'fix_failed',
'fix_verified',
'fix_verification_failed',
'fix_verification_unknown',
]);