Fix action verification read projections

This commit is contained in:
rcourtman
2026-05-13 18:26:29 +01:00
parent f024d3b560
commit dbe31bd8d6
7 changed files with 292 additions and 10 deletions
+58
View File
@@ -646,6 +646,64 @@ func TestAgentEventBroadcaster_PublishActionCompletedRoundTripsVerification(t *t
}
}
func TestProjectAgentActionCompletedPayloadUsesCanonicalVerification(t *testing.T) {
ranAt := time.Now().UTC()
payload, ok := projectAgentActionCompletedPayload(unifiedresources.ActionAuditRecord{
ID: "action-router-canonical-verification",
UpdatedAt: ranAt,
State: unifiedresources.ActionStateCompleted,
Request: unifiedresources.ActionRequest{
ResourceID: "vm:router-canonical",
CapabilityName: "restart_service",
RequestedBy: "agent:ops",
},
Result: &unifiedresources.ExecutionResult{Success: true},
Verification: &unifiedresources.ActionVerificationResult{
Ran: true,
Success: true,
Command: "systemctl is-active nginx",
RanAt: ranAt,
},
})
if !ok {
t.Fatal("expected terminal action to project")
}
if payload.Verification == nil || payload.Verification.Command != "systemctl is-active nginx" {
t.Fatalf("top-level canonical verification did not project onto router payload: %+v", payload.Verification)
}
payload, ok = projectAgentActionCompletedPayload(unifiedresources.ActionAuditRecord{
ID: "action-router-unrun-verification",
UpdatedAt: ranAt,
State: unifiedresources.ActionStateCompleted,
Request: unifiedresources.ActionRequest{
ResourceID: "vm:router-unrun",
CapabilityName: "restart_service",
RequestedBy: "agent:ops",
},
Result: &unifiedresources.ExecutionResult{
Success: true,
Verification: &unifiedresources.ActionVerificationResult{
Ran: false,
Success: true,
Command: "should not leak",
Output: "sensitive output",
Note: "sensitive note",
RanAt: ranAt,
},
},
})
if !ok {
t.Fatal("expected terminal action to project")
}
if payload.Verification == nil || payload.Verification.Ran {
t.Fatalf("expected sanitized ran=false verification, got %+v", payload.Verification)
}
if payload.Verification.Command != "" || payload.Verification.Note != "" || !payload.Verification.RanAt.IsZero() || payload.Verification.Success {
t.Fatalf("router payload leaked ran=false verification details: %+v", payload.Verification)
}
}
func TestAgentEventBroadcaster_PublishActionCompletedAbsentVerificationOmitsField(t *testing.T) {
// Refused-before-dispatch failures have no verification result
// (verification only runs after a successful execute). The
+3 -3
View File
@@ -515,9 +515,9 @@ func projectAgentResourceActions(
if audit.Result != nil {
summary.Success = audit.Result.Success
summary.ErrorMessage = audit.Result.ErrorMessage
if v := projectAgentResourceVerification(audit.Result.Verification); v != nil {
summary.Verification = v
}
}
if v := projectAgentResourceVerification(unified.CanonicalActionVerification(audit)); v != nil {
summary.Verification = v
}
out = append(out, summary)
}
@@ -469,6 +469,64 @@ func TestHandleAgentResourceContext_RedactsCommandsForMonitoringReadTokens(t *te
}
}
func TestProjectAgentResourceActionsUsesCanonicalVerification(t *testing.T) {
ranAt := time.Now().UTC()
summaries := projectAgentResourceActions([]unified.ActionAuditRecord{
{
ID: "action-resource-canonical-verification",
CreatedAt: ranAt.Add(-time.Minute),
UpdatedAt: ranAt,
State: unified.ActionStateCompleted,
Request: unified.ActionRequest{
ResourceID: "vm:resource-canonical",
CapabilityName: "restart_service",
RequestedBy: "agent:ops",
},
Result: &unified.ExecutionResult{Success: true},
Verification: &unified.ActionVerificationResult{
Ran: true,
Success: true,
Command: "systemctl is-active nginx",
RanAt: ranAt,
},
},
{
ID: "action-resource-unrun-verification",
CreatedAt: ranAt.Add(-2 * time.Minute),
UpdatedAt: ranAt.Add(-time.Minute),
State: unified.ActionStateCompleted,
Request: unified.ActionRequest{
ResourceID: "vm:resource-unrun",
CapabilityName: "restart_service",
RequestedBy: "agent:ops",
},
Result: &unified.ExecutionResult{
Success: true,
Verification: &unified.ActionVerificationResult{
Ran: false,
Success: true,
Command: "should not leak",
Output: "sensitive output",
Note: "sensitive note",
RanAt: ranAt,
},
},
},
})
if len(summaries) != 2 {
t.Fatalf("summaries len = %d, want 2", len(summaries))
}
if summaries[0].Verification == nil || summaries[0].Verification.Command != "systemctl is-active nginx" {
t.Fatalf("top-level canonical verification did not project onto resource summary: %+v", summaries[0].Verification)
}
if summaries[1].Verification == nil || summaries[1].Verification.Ran {
t.Fatalf("expected sanitized ran=false verification, got %+v", summaries[1].Verification)
}
if summaries[1].Verification.Command != "" || summaries[1].Verification.Note != "" || !summaries[1].Verification.RanAt.IsZero() || summaries[1].Verification.Success {
t.Fatalf("resource summary leaked ran=false verification details: %+v", summaries[1].Verification)
}
}
func TestHandleAgentResourceContext_PendingApprovalsEmptyArrayWhenNone(t *testing.T) {
// Absent or empty must surface as an empty array, not as a
// missing field — agents iterate without nil-checking. This
+7 -3
View File
@@ -13874,7 +13874,8 @@ func TestContract_ActionCompletedPayloadCarriesVerification(t *testing.T) {
// TestContract_RouterBridgesVerificationOntoActionCompleted pins
// that the router-side bridge actually carries the verification
// projection from record.Result.Verification onto the SSE payload.
// projection from the canonical top-level verification helper onto
// the SSE payload.
// The payload field exists is one half of the contract; this is the
// other half — that the bridge populates it when the underlying
// audit has a verification result.
@@ -13884,8 +13885,8 @@ func TestContract_RouterBridgesVerificationOntoActionCompleted(t *testing.T) {
t.Fatalf("read router.go: %v", err)
}
src := string(source)
if !strings.Contains(src, "if v := projectAgentResourceVerification(record.Result.Verification); v != nil {") {
t.Error("router.go must project record.Result.Verification onto the action.completed payload via projectAgentResourceVerification — drift here means verification reaches the audit store but never the SSE stream")
if !strings.Contains(src, "if v := projectAgentResourceVerification(unifiedresources.CanonicalActionVerification(record)); v != nil {") {
t.Error("router.go must project canonical action verification onto the action.completed payload via projectAgentResourceVerification — drift here means verification reaches the audit store but never the SSE stream")
}
if !strings.Contains(src, "payload.Verification = v") {
t.Error("router.go must assign the projected verification onto payload.Verification")
@@ -13911,6 +13912,9 @@ func TestContract_AgentResourceActionSummaryCarriesVerification(t *testing.T) {
if !strings.Contains(src, "func projectAgentResourceVerification(v *unified.ActionVerificationResult)") {
t.Error("projectAgentResourceVerification must exist as the shared helper — both the SSE bridge and the bundle's projector route through it so the wire shape cannot drift between surfaces")
}
if !strings.Contains(src, "projectAgentResourceVerification(unified.CanonicalActionVerification(audit))") {
t.Error("AgentResourceActionSummary must project canonical action verification, not only legacy result.verification")
}
}
// TestContract_OperatorStateWriteServerPopulatesAttribution pins the
+3 -3
View File
@@ -2999,9 +2999,9 @@ func projectAgentActionCompletedPayload(record unifiedresources.ActionAuditRecor
if record.Result != nil {
payload.Success = record.Result.Success
payload.ErrorMessage = record.Result.ErrorMessage
if v := projectAgentResourceVerification(record.Result.Verification); v != nil {
payload.Verification = v
}
}
if v := projectAgentResourceVerification(unifiedresources.CanonicalActionVerification(record)); v != nil {
payload.Verification = v
}
return payload, true
}
+27 -1
View File
@@ -1107,7 +1107,33 @@ func scanActionAuditRecord(scanner actionAuditScanner) (ActionAuditRecord, error
record.Request.RequestID = requestID
record.Request.ResourceID = CanonicalResourceID(record.Request.ResourceID)
_ = actionID
return record, nil
return normalizeActionAuditRecordFromStore(record), nil
}
func normalizeActionAuditRecordFromStore(record ActionAuditRecord) ActionAuditRecord {
normalized, err := NormalizeActionAuditRecord(record)
if err == nil {
return normalized
}
if record.Result != nil {
result := *record.Result
result.Output = strings.TrimSpace(result.Output)
result.ErrorMessage = strings.TrimSpace(result.ErrorMessage)
result.Verification = NormalizeActionVerificationResult(result.Verification)
record.Result = &result
}
record.Verification = NormalizeActionVerificationResult(record.Verification)
if record.Verification == nil && record.Result != nil {
record.Verification = cloneActionVerificationResult(record.Result.Verification)
}
if record.Verification != nil && record.Result != nil {
result := *record.Result
result.Verification = cloneActionVerificationResult(record.Verification)
record.Result = &result
}
record.VerificationOutcome = NormalizeVerificationOutcome(record.VerificationOutcome)
return record
}
func (s *SQLiteResourceStore) GetActionAudit(actionID string) (ActionAuditRecord, bool, error) {
+136
View File
@@ -2,6 +2,7 @@ package unifiedresources
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
@@ -426,6 +427,37 @@ func newTestStore(t *testing.T) *SQLiteResourceStore {
return store
}
func insertRawActionAuditForTest(t *testing.T, store *SQLiteResourceStore, record ActionAuditRecord) {
t.Helper()
requestJSON, err := json.Marshal(record.Request)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
planJSON, err := json.Marshal(record.Plan)
if err != nil {
t.Fatalf("marshal plan: %v", err)
}
approvalsJSON, err := json.Marshal(record.Approvals)
if err != nil {
t.Fatalf("marshal approvals: %v", err)
}
resultJSON, err := json.Marshal(record.Result)
if err != nil {
t.Fatalf("marshal result: %v", err)
}
verificationOutcomeJSON, err := json.Marshal(record.VerificationOutcome)
if err != nil {
t.Fatalf("marshal verification outcome: %v", err)
}
_, err = store.db.Exec(`
INSERT INTO action_audits (id, action_id, canonical_id, request_id, created_at, updated_at, state, request_json, plan_json, approvals_json, result_json, verification_outcome_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, record.ID, record.ID, CanonicalResourceID(record.Request.ResourceID), record.Request.RequestID, record.CreatedAt, record.UpdatedAt, string(record.State), string(requestJSON), string(planJSON), string(approvalsJSON), string(resultJSON), string(verificationOutcomeJSON))
if err != nil {
t.Fatalf("insert raw action audit: %v", err)
}
}
func TestRecordChange_RoundTrip(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC().Truncate(time.Second)
@@ -1288,11 +1320,115 @@ func TestActionAuditRecord_RoundTrip(t *testing.T) {
if verification == nil || !verification.Ran || verification.Command != result.Verification.Command {
t.Fatalf("canonical verification round-trip failed: %+v", verification)
}
if got.Verification == nil || got.Verification.Command != verification.Command {
t.Fatalf("top-level verification was not restored from sqlite row: top-level=%+v canonical=%+v", got.Verification, verification)
}
if got.Result.Verification == nil || got.Result.Verification.Command != verification.Command {
t.Fatalf("result verification did not stay aligned with canonical verification: result=%+v canonical=%+v", got.Result.Verification, verification)
}
}
func TestActionAuditRecord_RoundTripLegacyResultVerificationCanonicalizesSQLiteRead(t *testing.T) {
store := newTestStore(t)
now := time.Date(2026, 3, 18, 14, 0, 0, 0, time.UTC)
insertRawActionAuditForTest(t, store, ActionAuditRecord{
ID: "action-legacy-verification",
CreatedAt: now,
UpdatedAt: now.Add(time.Minute),
State: ActionStateCompleted,
Request: ActionRequest{
RequestID: "req-legacy-verification",
ResourceID: "vm:legacy-verification",
CapabilityName: "restart",
RequestedBy: "agent:test",
},
Plan: ActionPlan{
ActionID: "action-legacy-verification",
RequestID: "req-legacy-verification",
Allowed: true,
},
Result: &ExecutionResult{
Success: true,
Verification: &ActionVerificationResult{
Ran: true,
Success: true,
Command: "systemctl is-active nginx",
Output: "active",
RanAt: now.Add(30 * time.Second),
},
},
})
got, ok, err := store.GetActionAudit("action-legacy-verification")
if err != nil {
t.Fatalf("GetActionAudit: %v", err)
}
if !ok {
t.Fatal("expected raw legacy action audit row")
}
if got.Verification == nil || got.Verification.Command != "systemctl is-active nginx" {
t.Fatalf("legacy result.verification was not restored onto top-level verification: %+v", got.Verification)
}
if got.Result == nil || got.Result.Verification == nil || got.Result.Verification.Command != got.Verification.Command {
t.Fatalf("legacy result verification was not kept aligned: result=%+v canonical=%+v", got.Result, got.Verification)
}
}
func TestActionAuditRecord_RoundTripMalformedUnrunVerificationScrubsSQLiteRead(t *testing.T) {
store := newTestStore(t)
now := time.Date(2026, 3, 18, 14, 30, 0, 0, time.UTC)
insertRawActionAuditForTest(t, store, ActionAuditRecord{
ID: "action-malformed-unrun-verification",
CreatedAt: now,
UpdatedAt: now.Add(time.Minute),
State: ActionStateCompleted,
Request: ActionRequest{
RequestID: "req-malformed-unrun-verification",
ResourceID: "vm:malformed-unrun-verification",
CapabilityName: "restart",
},
Plan: ActionPlan{
ActionID: "action-malformed-unrun-verification",
RequestID: "req-malformed-unrun-verification",
Allowed: true,
},
Result: &ExecutionResult{
Success: true,
Verification: &ActionVerificationResult{
Ran: false,
Success: true,
Command: "should not leak",
Output: "sensitive output",
Note: "sensitive note",
RanAt: now.Add(30 * time.Second),
},
},
})
results, err := store.GetActionAudits("vm:malformed-unrun-verification", now.Add(-time.Hour), 10)
if err != nil {
t.Fatalf("GetActionAudits: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 raw malformed action audit row, got %d", len(results))
}
got := results[0]
if got.Verification == nil || got.Verification.Ran {
t.Fatalf("expected sanitized ran=false verification, got %+v", got.Verification)
}
if got.Verification.Command != "" || got.Verification.Output != "" || got.Verification.Note != "" || !got.Verification.RanAt.IsZero() || got.Verification.Success {
t.Fatalf("top-level ran=false verification leaked details: %+v", got.Verification)
}
if got.Result == nil || got.Result.Verification == nil {
t.Fatalf("expected result verification to remain present and aligned: %+v", got.Result)
}
if got.Result.Verification.Command != "" || got.Result.Verification.Output != "" || got.Result.Verification.Note != "" || !got.Result.Verification.RanAt.IsZero() || got.Result.Verification.Success {
t.Fatalf("result ran=false verification leaked details: %+v", got.Result.Verification)
}
}
func TestMemoryStore_RecordActionAudit_UpsertsByID(t *testing.T) {
store := NewMemoryStore()
now := time.Date(2026, 3, 18, 13, 30, 0, 0, time.UTC)