Persist Patrol approval requester identity

- store requester provenance on approval records
- carry requester metadata through approval APIs and Assistant handoffs
- document the safe Patrol approval provenance boundary
This commit is contained in:
rcourtman 2026-05-08 00:12:09 +01:00
parent f65505ba04
commit ea3e1b216a
16 changed files with 156 additions and 16 deletions

View file

@ -289,8 +289,9 @@ profile and assignment columns, but embedded table framing must route through
agent command grant or lifecycle authorization primitive.
Patrol queued-fix approvals may now seed the shared action-audit store with
planned and pending lifecycle evidence, but lifecycle surfaces must treat
those records as action-governance context only; they do not create install,
enrollment, assignment, or fleet-command authority.
those records and their persisted requester identity as action-governance
context only; they do not create install, enrollment, assignment, or
fleet-command authority.
Lifecycle flows must not reintroduce anonymous bootstrap identity,
tenant-local commercial-owner surrogates, or fake activation records when
they traverse those shared handlers. They also must not infer tenant

View file

@ -660,7 +660,10 @@ attach a governed action plan, seed the shared action-audit store as planned
and pending with `pulse_patrol` as the requester/actor, and leave later
execution or approval decisions to the governed action/approval paths instead
of creating Patrol-only execution context or collapsing Patrol proposals into
generic Assistant-origin actions.
generic Assistant-origin actions. The approval record itself must also persist
and expose that requester identity so `/api/ai/approvals` and Assistant
handoffs preserve Patrol provenance before later action-audit hydration refreshes
the current action state.
The same ownership includes the Pulse query tool schema under
`internal/ai/tools/`: topology-query input names must stay canonical inside
the AI runtime itself, so new tool arguments such as `max_proxmox_nodes`

View file

@ -439,7 +439,10 @@ the canonical monitored-system blocked payload.
hydration. Queueing a Patrol investigation fix must stamp the action request
and initial lifecycle events as `pulse_patrol`; it must not execute the fix,
create a Patrol-local audit record that bypasses `/api/actions`, or present
Patrol-origin proposals as generic Assistant-origin actions.
Patrol-origin proposals as generic Assistant-origin actions. `/api/ai/approvals`
must expose the persisted `requestedBy` identity so frontend Assistant
handoffs can carry Patrol provenance before the chat runtime refreshes the
current action state from action audit.
Action execution is API-owned as the next explicit contract:
`POST /api/actions/{id}/execute` may only start execution for an approved
action or an approval-free executable plan, must atomically persist the
@ -3157,8 +3160,9 @@ server-refreshed approval, resource, or action authority. Frontend Patrol
handoff helpers may consume current pending
approval list payloads only as safe metadata for that visible briefing and any
structured `handoff_actions`: approval ID, status, risk, request/expiry
timestamps, target label, action ID, approval policy, plan expiry, and dry-run
summary are allowed. Assessment-level visible briefings may reuse that same
timestamps, target label, requester identity, action ID, approval policy,
plan expiry, and dry-run summary are allowed. Assessment-level visible
briefings may reuse that same
safe metadata for action labels, safety notes, and approval-aware suggested
prompts, while approval command text remains inside the governed
approval/remediation surface.

View file

@ -877,8 +877,9 @@ frontend primitive boundary.
assessment handoff may add live pending
approval posture only as safe structured metadata: approval ID, pending
status, risk, target, requested/expiry timestamps, action plan identity,
approval policy, plan expiry, dry-run posture, and command count. Those
entries may be passed through shared chat transport as `handoff_actions` for
requester identity, approval policy, plan expiry, dry-run posture, and
command count. Those entries may be passed through shared chat transport as
`handoff_actions` for
model-only refresh, but the shared drawer stays a generic shell rather than
a Patrol summary prompt builder. The Patrol helper may turn those same safe
references into visible action labels, safety notes, and approval-aware

View file

@ -854,7 +854,10 @@ Assistant handoffs and resource timelines can hydrate the same canonical
action record before any operator decision or execution occurs. Those queued
fix records must carry `pulse_patrol` as the requester and lifecycle actor so
resource timelines preserve the product source of the proposal instead of
flattening proactive Patrol work into generic Assistant chat activity.
flattening proactive Patrol work into generic Assistant chat activity. Pending
approval payloads and Patrol Assistant handoff actions must carry the same
requester identity as safe metadata, without copying the approval command
payload into Assistant.
That same store now owns the Patrol dashboard load bundle as well, so the
page refresh path stays aligned on a single orchestrated AI bundle instead of
repeating the individual summary, findings, approval, and correlation fetches

View file

@ -1124,9 +1124,10 @@ freshness, restore authorization, storage remediation permission, or recovery
transport state.
Patrol queued-fix approvals that seed shared action-audit records follow the
same rule: storage and recovery may display the resulting action history as
incident-adjacent context, but they must not treat the pending action state,
approval policy, or preflight posture as recovery proof or storage-local
execution permission.
incident-adjacent context, including the requester identity that distinguishes
Patrol-origin proposals from generic Assistant work, but they must not treat the
pending action state, approval policy, requester, or preflight posture as
recovery proof or storage-local execution permission.
That same storage ownership also includes the shared storage-source presentation
contract in `frontend-modern/src/utils/storageSources.ts`: storage pages and
cross-surface storage links must reuse one canonical ordering, label, tone, and

View file

@ -186,6 +186,24 @@ describe('AIAPI', () => {
await expect(AIAPI.getPendingApprovals()).resolves.toEqual([]);
});
it('preserves approval requester identity from pending approvals', async () => {
apiFetchJSONMock.mockResolvedValueOnce({
approvals: [
{
id: 'approval-1',
requestedBy: 'pulse_patrol',
},
],
} as any);
await expect(AIAPI.getPendingApprovals()).resolves.toEqual([
expect.objectContaining({
id: 'approval-1',
requestedBy: 'pulse_patrol',
}),
]);
});
it('normalizes remediation plans from optional or legacy collection payloads', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ plans: [{ id: 'plan-1' }] } as any);
await expect(AIAPI.getRemediationPlans()).resolves.toEqual({

View file

@ -584,6 +584,7 @@ export interface ApprovalRequest {
targetId: string;
targetName: string;
context: string;
requestedBy?: string;
riskLevel: RiskLevel;
status: ApprovalStatus;
requestedAt: string;

View file

@ -4,6 +4,7 @@ import type { PatrolRunRecord } from '@/api/patrol';
import {
buildPatrolAssessmentAssistantHandoff,
buildPatrolAssistantApprovalBriefingInput,
buildPatrolAssistantFindingBriefing,
buildPatrolAssistantFindingHandoff,
buildPatrolAssistantFindingHandoffActions,
@ -375,6 +376,7 @@ describe('patrolInvestigationContextModel', () => {
actionPlanMessage: 'Restart after the backup window clears.',
actionPreflight: 'Restart workload service',
actionDryRunSummary: 'No provider-supported dry run is available for this action.',
actionRequestedBy: 'pulse_patrol',
},
proposedFix: {
description: 'Restart the workload service',
@ -396,6 +398,7 @@ describe('patrolInvestigationContextModel', () => {
expect(handoff.context.handoffContext).toContain('high risk');
expect(handoff.context.handoffContext).toContain('approval target web-server');
expect(handoff.context.handoffContext).toContain('expires 2026-05-06T12:10:00Z');
expect(handoff.context.handoffContext).toContain('requested by pulse_patrol');
expect(handoff.context.handoffContext).toContain('1 command recorded for approval context');
expect(handoff.context.handoffContext).toContain('destructive proposed fix');
expect(handoff.context.handoffActions).toHaveLength(1);
@ -406,6 +409,7 @@ describe('patrolInvestigationContextModel', () => {
approvalRequestedAt: '2026-05-06T12:00:00Z',
approvalExpiresAt: '2026-05-06T12:10:00Z',
actionId: 'action-1',
actionRequestedBy: 'pulse_patrol',
actionApprovalPolicy: 'admin',
actionRequiresApproval: true,
actionPlanExpiresAt: '2026-05-06T12:10:00Z',
@ -641,6 +645,35 @@ describe('patrolInvestigationContextModel', () => {
expect(JSON.stringify(briefing)).not.toContain('systemctl restart workload.service');
});
it('carries approval requester identity into safe Patrol handoff metadata', () => {
const briefing = buildPatrolAssistantApprovalBriefingInput({
id: 'approval-1',
toolId: 'investigation_fix',
command: 'systemctl restart nginx',
targetType: 'investigation',
targetId: 'finding-1',
targetName: 'node-1',
context: 'Restart nginx after Patrol investigation',
requestedBy: 'pulse_patrol',
riskLevel: 'high',
status: 'pending',
requestedAt: '2026-05-06T12:00:00Z',
expiresAt: '2026-05-06T12:10:00Z',
plan: {
actionId: 'action-1',
approvalPolicy: 'admin',
},
});
expect(briefing).toMatchObject({
id: 'approval-1',
actionId: 'action-1',
actionApprovalPolicy: 'admin',
actionRequestedBy: 'pulse_patrol',
});
expect(JSON.stringify(briefing)).not.toContain('systemctl restart nginx');
});
it('frames Assistant handoff around the structured Patrol record when one exists', () => {
expect(
buildPatrolAssistantFindingPrompt({
@ -737,6 +770,7 @@ describe('patrolInvestigationContextModel', () => {
actionPlanMessage: 'Restart nginx after validating load balancer drain.',
actionPreflight: 'Would restart nginx on node-1.',
actionDryRunSummary: 'One service restart would be attempted.',
actionRequestedBy: 'pulse_patrol',
},
proposedFix: buildPatrolAssistantProposedFixBriefingInput({
description: 'Restart nginx',
@ -756,6 +790,7 @@ describe('patrolInvestigationContextModel', () => {
approvalRequestedAt: '2026-05-06T12:00:00Z',
approvalExpiresAt: '2026-05-06T12:10:00Z',
actionId: 'restart-nginx',
actionRequestedBy: 'pulse_patrol',
actionApprovalPolicy: 'operator',
actionRequiresApproval: true,
actionPlanExpiresAt: '2026-05-06T12:10:00Z',
@ -818,6 +853,7 @@ describe('patrolInvestigationContextModel', () => {
actionPlanMessage: 'Restart nginx after validating load balancer drain.',
actionPreflight: 'Would restart nginx on node-1.',
actionDryRunSummary: 'One service restart would be attempted.',
actionRequestedBy: 'pulse_patrol',
},
proposedFix: buildPatrolAssistantProposedFixBriefingInput({
description: 'Restart nginx',
@ -846,6 +882,7 @@ describe('patrolInvestigationContextModel', () => {
});
expect(handoff.context.handoffContext).toContain('[Patrol Finding Context]');
expect(handoff.context.handoffContext).toContain('Approval: approval-1');
expect(handoff.context.handoffContext).toContain('Action Requested By: pulse_patrol');
expect(handoff.context.handoffContext).toContain(
'Dry-Run Posture: One service restart would be attempted.',
);

View file

@ -95,6 +95,7 @@ export interface PatrolAssistantApprovalBriefingInput {
actionPlanMessage?: string | null;
actionPreflight?: string | null;
actionDryRunSummary?: string | null;
actionRequestedBy?: string | null;
}
export interface PatrolAssistantProposedFixBriefingInput {
@ -414,6 +415,7 @@ export function buildPatrolAssistantApprovalBriefingInput(
actionPlanMessage: normalizeText(approval.plan?.message || approval.plan?.summary),
actionPreflight: normalizeText(approval.preflight?.intendedChange),
actionDryRunSummary: normalizeText(approval.preflight?.dryRunSummary),
actionRequestedBy: normalizeText(approval.requestedBy),
};
}
@ -1448,6 +1450,7 @@ function buildPatrolFindingHandoffAction(
approvalRequestedAt: pendingApproval.requestedAt || undefined,
approvalExpiresAt: pendingApproval.expiresAt || undefined,
actionId: pendingApproval.actionId || undefined,
actionRequestedBy: pendingApproval.actionRequestedBy || undefined,
actionApprovalPolicy: pendingApproval.actionApprovalPolicy || undefined,
actionRequiresApproval: Boolean(approvalId),
actionPlanExpiresAt: pendingApproval.actionPlanExpiresAt || undefined,
@ -1937,6 +1940,7 @@ function buildPatrolAssistantFindingModelContext(
formatContextLine('Approval Requested At', pendingApproval.requestedAt),
formatContextLine('Approval Expires At', pendingApproval.expiresAt),
formatContextLine('Approval Policy', pendingApproval.actionApprovalPolicy),
formatContextLine('Action Requested By', pendingApproval.actionRequestedBy),
formatContextLine('Approval Plan Expires At', pendingApproval.actionPlanExpiresAt),
formatContextLine('Action Plan Summary', pendingApproval.actionPlanMessage),
formatContextLine('Action Preflight', pendingApproval.actionPreflight),
@ -1978,6 +1982,9 @@ function formatAssessmentPendingApprovalContextParts(
if (approval.requestedAt) {
parts.push(`requested ${approval.requestedAt}`);
}
if (approval.actionRequestedBy) {
parts.push(`requested by ${approval.actionRequestedBy}`);
}
return parts;
}
@ -2371,6 +2378,9 @@ function buildPatrolAssistantOperatorDecision(
if (pendingApproval.requestedAt) {
parts.push(`requested ${pendingApproval.requestedAt}`);
}
if (pendingApproval.actionRequestedBy) {
parts.push(`requested by ${pendingApproval.actionRequestedBy}`);
}
if (record.proposed_fix) {
const fixId = normalizeText(record.proposed_fix.id);
if (fixId) {
@ -2435,6 +2445,9 @@ function buildPatrolAssistantOperatorDecision(
if (pendingApproval.requestedAt) {
parts.push(`Requested: ${pendingApproval.requestedAt}.`);
}
if (pendingApproval.actionRequestedBy) {
parts.push(`Requested by: ${pendingApproval.actionRequestedBy}.`);
}
return parts.join(' ');
}
@ -2559,6 +2572,7 @@ function normalizeApprovalBriefing(
actionPlanMessage: normalizeText(approval?.actionPlanMessage),
actionPreflight: normalizeText(approval?.actionPreflight),
actionDryRunSummary: normalizeText(approval?.actionDryRunSummary),
actionRequestedBy: normalizeText(approval?.actionRequestedBy),
};
}

View file

@ -83,7 +83,8 @@ type ApprovalRequest struct {
TargetType string `json:"targetType"` // agent, container, vm, node
TargetID string `json:"targetId"`
TargetName string `json:"targetName"`
Context string `json:"context"` // Why AI wants to run this
Context string `json:"context"` // Why AI wants to run this
RequestedBy string `json:"requestedBy,omitempty"`
RiskLevel RiskLevel `json:"riskLevel"` // low, medium, high
Status ApprovalStatus `json:"status"`
RequestedAt time.Time `json:"requestedAt"`
@ -131,6 +132,9 @@ func RequesterForRequest(req *ApprovalRequest) string {
if req == nil {
return RequesterPulseAssistant
}
if requester := strings.TrimSpace(req.RequestedBy); requester != "" {
return requester
}
if strings.TrimSpace(req.ToolID) == "investigation_fix" ||
strings.TrimSpace(req.TargetType) == "investigation" {
return RequesterPulsePatrol
@ -258,6 +262,7 @@ func (s *Store) CreateApproval(req *ApprovalRequest) error {
if req.ExpiresAt.IsZero() {
req.ExpiresAt = req.RequestedAt.Add(s.defaultTimeout)
}
req.RequestedBy = RequesterForRequest(req)
if req.Plan != nil {
if strings.TrimSpace(req.Plan.ActionID) == "" {
req.Plan.ActionID = uuid.New().String()

View file

@ -287,6 +287,9 @@ func TestCreateApproval_UsesPatrolRequesterForInvestigationFixPreflight(t *testi
if got := RequesterForRequest(req); got != RequesterPulsePatrol {
t.Fatalf("RequesterForRequest = %q, want %q", got, RequesterPulsePatrol)
}
if req.RequestedBy != RequesterPulsePatrol {
t.Fatalf("requestedBy = %q, want %q", req.RequestedBy, RequesterPulsePatrol)
}
if req.Plan.Preflight == nil {
t.Fatal("expected normalized action preflight")
}
@ -298,6 +301,47 @@ func TestCreateApproval_UsesPatrolRequesterForInvestigationFixPreflight(t *testi
}
}
func TestCreateApproval_PreservesExplicitRequester(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "approval-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
store, _ := NewStore(StoreConfig{
DataDir: tmpDir,
DefaultTimeout: 1 * time.Minute,
DisablePersistence: true,
})
req := &ApprovalRequest{
ID: "approval-explicit-requester",
ToolID: "investigation_fix",
Command: "systemctl restart nginx",
TargetType: "investigation",
TargetID: "finding-123",
Context: "Restart nginx after Patrol investigation",
RequestedBy: "pulse_custom",
Plan: &unifiedresources.ActionPlan{
ActionID: "action-explicit-requester",
Allowed: true,
RequiresApproval: true,
ApprovalPolicy: unifiedresources.ApprovalAdmin,
PlannedAt: time.Now().UTC(),
},
}
if err := store.CreateApproval(req); err != nil {
t.Fatalf("CreateApproval() error = %v", err)
}
if req.RequestedBy != "pulse_custom" {
t.Fatalf("requestedBy = %q, want pulse_custom", req.RequestedBy)
}
if got := RequesterForRequest(req); got != "pulse_custom" {
t.Fatalf("RequesterForRequest = %q, want pulse_custom", got)
}
}
func TestCreateApproval_RejectsUnsupportedHostTargetType(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "approval-test-*")
if err != nil {

View file

@ -2636,9 +2636,10 @@ func TestAISettingsHandler_Approvals(t *testing.T) {
appID := "app-123"
_ = approvalStore.CreateApproval(&approval.ApprovalRequest{
ID: appID,
Command: "ls -la",
Status: approval.StatusPending,
ID: appID,
Command: "ls -la",
RequestedBy: approval.RequesterPulsePatrol,
Status: approval.StatusPending,
})
t.Run("HandleGetApproval", func(t *testing.T) {
@ -2651,6 +2652,7 @@ func TestAISettingsHandler_Approvals(t *testing.T) {
err := json.Unmarshal(rec.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, appID, resp.ID)
assert.Equal(t, approval.RequesterPulsePatrol, resp.RequestedBy)
})
t.Run("HandleListApprovals", func(t *testing.T) {
@ -2667,6 +2669,7 @@ func TestAISettingsHandler_Approvals(t *testing.T) {
require.NoError(t, err)
require.Len(t, resp.Approvals, 1)
assert.Equal(t, appID, resp.Approvals[0].ID)
assert.Equal(t, approval.RequesterPulsePatrol, resp.Approvals[0].RequestedBy)
assert.Equal(t, 1, resp.Stats["pending"])
})

View file

@ -2951,6 +2951,7 @@ func TestContract_ApprovalJSONSnapshot(t *testing.T) {
TargetID: "host-1",
TargetName: "alpha",
Context: "Cleanup temporary cache",
RequestedBy: approval.RequesterPulsePatrol,
RiskLevel: approval.RiskHigh,
Status: approval.StatusApproved,
RequestedAt: now,
@ -2976,6 +2977,7 @@ func TestContract_ApprovalJSONSnapshot(t *testing.T) {
"targetId":"host-1",
"targetName":"alpha",
"context":"Cleanup temporary cache",
"requestedBy":"pulse_patrol",
"riskLevel":"high",
"status":"approved",
"requestedAt":"2026-02-08T13:14:15Z",

View file

@ -466,6 +466,7 @@ func (a *approvalStoreAdapter) CreateApproval(info *aicontracts.ApprovalInfo) er
TargetID: info.TargetID,
TargetName: info.TargetName,
Context: info.Context,
RequestedBy: info.RequestedBy,
RiskLevel: approval.RiskLevel(info.RiskLevel),
Plan: approvalPlanInfoToRequest(info.Plan),
ContextConfidence: contextConfidenceInfoToRequest(info.ContextConfidence),
@ -519,6 +520,7 @@ func approvalRequestToInfo(req *approval.ApprovalRequest) *aicontracts.ApprovalI
TargetID: req.TargetID,
TargetName: req.TargetName,
Context: req.Context,
RequestedBy: req.RequestedBy,
RiskLevel: string(req.RiskLevel),
Status: string(req.Status),
RequestedAt: req.RequestedAt,

View file

@ -24,6 +24,7 @@ type ApprovalInfo struct {
TargetID string `json:"targetId"`
TargetName string `json:"targetName"`
Context string `json:"context"`
RequestedBy string `json:"requestedBy,omitempty"`
RiskLevel string `json:"riskLevel"`
Status string `json:"status"`
RequestedAt time.Time `json:"requestedAt"`