From c07927adbda2498742a66271da5aa2d79915df12 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 12 Jul 2026 13:55:19 +0100 Subject: [PATCH] Classify action routes and operation transports --- .github/workflows/canonical-governance.yml | 2 +- .../v6/internal/subsystems/ai-runtime.md | 12 + internal/mutationregistry/audit.go | 67 ++++++ internal/mutationregistry/manifest.json | 1 + .../mutationregistry/registry_generated.go | 1 + .../runtime_surface_audit_test.go | 208 +++++++++++++----- 6 files changed, 229 insertions(+), 62 deletions(-) diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index b4c702c99..a559b82a2 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -116,7 +116,7 @@ jobs: run: python3 scripts/release_control/registry_audit.py --check - name: Run canonical mutation registry audits - run: go test ./internal/mutationregistry ./internal/ai/tools -run 'Test(EveryRegisteredMutationHasDisposition|InfrastructureAPIRoutesResolveToRegistry|TransportCommandCatalogsResolveToRegistry|PatrolJobRegistrationResolvesToRegistry|RuntimeCandidateAuditNegativeFixtures|RegisteredModelMutationSchemasResolveToClosedRegistry|RetiredMutationAliasesCannotShadowExtensions)' -count=1 + run: go test ./internal/mutationregistry ./internal/ai/tools -run 'Test(EveryRegisteredMutationHasDisposition|InfrastructureAPIRoutesResolveToRegistry|TransportCommandCatalogsResolveToRegistry|PatrolJobRegistrationResolvesToRegistry|RuntimeCandidateAuditNegativeFixtures|ActionRouteMethodAuthorityIsExactAndLookalikesFailClosed|NonAdmittingTransportMessagesCannotCarryDispatchAuthority|UnknownTransportLookalikeFailsClosed|RegisteredModelMutationSchemasResolveToClosedRegistry|RetiredMutationAliasesCannotShadowExtensions)' -count=1 - name: Run contract audit run: python3 scripts/release_control/contract_audit.py --check diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 6cb08a12b..8d7b27095 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -5778,6 +5778,18 @@ Production code contains no command or rollback execution algorithm; exported approve/execute/rollback interfaces and HTTP endpoints are permanently inert even when a command executor is injected. +The agent transport catalogue now classifies wire roles independently from +mutation identity. `docker_container_lifecycle` is the typed mutation request +for the existing `resource.docker.container-lifecycle` capability and may be +sent only after committed action-lifecycle authority. +`agent_operation_query` is query-only reconciliation; +`agent_operation_query_result` and `docker_container_lifecycle_result` are +non-admitting receipt/result messages. Query, result, receipt, and general +protocol roles are forbidden from carrying a mutation registry id or durable +authority reference, so response-shaped lookalikes cannot become dispatch +entry points. Task 10 remains the sole owner of `ActionResultV2` execution, +verification, evidence, and compensation truth. + Action planning and approval attribution are now server-owned across Assistant, Patrol, and MCP projections. Trusted brokers use explicit service/policy actor contexts; public `requestedBy` content cannot become audit authority. The agent diff --git a/internal/mutationregistry/audit.go b/internal/mutationregistry/audit.go index 1282e8896..dacdc7a2a 100644 --- a/internal/mutationregistry/audit.go +++ b/internal/mutationregistry/audit.go @@ -14,6 +14,73 @@ type RuntimeCandidate struct { DurableAuthorityID string } +type TransportRole string + +const ( + TransportRoleMutationRequest TransportRole = "mutation_request" + TransportRoleAdministrativeRequest TransportRole = "administrative_request" + TransportRoleOperationQuery TransportRole = "operation_query" + TransportRoleOperationResult TransportRole = "operation_result" + TransportRoleOperationReceipt TransportRole = "operation_receipt" + TransportRoleProtocol TransportRole = "protocol" +) + +// TransportSurface classifies one actual wire-catalog member. Only mutation +// requests may bind lifecycle authority. Queries, results, receipts, and +// protocol messages are explicitly non-admitting and cannot carry a mutation +// registry identity or durable-authority reference. +type TransportSurface struct { + Name string + WireValue string + Role TransportRole + MutationID string + DurableAuthorityID string +} + +func AuditTransportSurfaces(surfaces []TransportSurface) error { + seenNames := make(map[string]struct{}, len(surfaces)) + seenWireValues := make(map[string]struct{}, len(surfaces)) + for _, surface := range surfaces { + name := strings.TrimSpace(surface.Name) + wireValue := strings.TrimSpace(surface.WireValue) + if name == "" || wireValue == "" { + return fmt.Errorf("transport surface has empty name or wire value: %+v", surface) + } + if _, duplicate := seenNames[name]; duplicate { + return fmt.Errorf("transport surface name %q is classified more than once", name) + } + if _, duplicate := seenWireValues[wireValue]; duplicate { + return fmt.Errorf("transport wire value %q is classified more than once", wireValue) + } + seenNames[name] = struct{}{} + seenWireValues[wireValue] = struct{}{} + + switch surface.Role { + case TransportRoleMutationRequest: + if err := AuditRuntimeCandidates([]RuntimeCandidate{{ + Surface: "transport:" + wireValue, + MutationID: surface.MutationID, + Transport: true, + DurableAuthorityID: surface.DurableAuthorityID, + }}); err != nil { + return err + } + case TransportRoleAdministrativeRequest: + entry, ok := Lookup(surface.MutationID) + if !ok || entry.Disposition != DispositionAdministrativeException || surface.DurableAuthorityID != "" { + return fmt.Errorf("administrative transport %q has invalid registry authority", wireValue) + } + case TransportRoleOperationQuery, TransportRoleOperationResult, TransportRoleOperationReceipt, TransportRoleProtocol: + if strings.TrimSpace(surface.MutationID) != "" || strings.TrimSpace(surface.DurableAuthorityID) != "" { + return fmt.Errorf("non-admitting transport %q cannot carry mutation authority", wireValue) + } + default: + return fmt.Errorf("transport %q has unknown role %q", wireValue, surface.Role) + } + } + return nil +} + // AuditRuntimeCandidates requires every mechanically discovered mutation // candidate to resolve to exactly one closed registry disposition. Transport // candidates additionally name the lifecycle authority that must exist before diff --git a/internal/mutationregistry/manifest.json b/internal/mutationregistry/manifest.json index 6b437c306..d68fefa46 100644 --- a/internal/mutationregistry/manifest.json +++ b/internal/mutationregistry/manifest.json @@ -38,6 +38,7 @@ {"id":"resource.host.package-update","origin":"api","resource_class":"customer_infrastructure","resource_kind":"agent","capability":"install_os_updates","entrypoint":"host.package_updates","disposition":"lifecycle","lifecycle_executor":"hostUpdateActionExecutor","approval_floor":"policy_or_admin","delivery":"committed_lifecycle_before_transport","verification":"required","rollback":"task_10_truth_and_compensation","residual_owners":["task_10_truth_and_compensation"]}, {"id":"resource.proxmox.guest-lifecycle","origin":"api","resource_class":"customer_infrastructure","resource_kind":"vm_or_system-container","capability":"start_shutdown_reboot_stop","entrypoint":"proxmox.vm.lifecycle/proxmox.ct.lifecycle","disposition":"lifecycle","lifecycle_executor":"proxmoxGuestActionExecutor","approval_floor":"admin","delivery":"committed_lifecycle_before_transport","verification":"required","rollback":"task_10_truth_and_compensation","residual_owners":["task_10_truth_and_compensation"]}, + {"id":"transport.agent.docker-container-lifecycle","origin":"transport","resource_class":"customer_infrastructure","resource_kind":"app-container","capability":"start_stop_restart","entrypoint":"agentexec docker_container_lifecycle","disposition":"lifecycle","lifecycle_executor":"dockerContainerActionExecutor","approval_floor":"admin","delivery":"committed_lifecycle_before_transport","verification":"required","rollback":"task_10_truth_and_compensation","residual_owners":["task_10_truth_and_compensation"]}, {"id":"transport.agent.host-package-cache-cleanup","origin":"transport","resource_class":"customer_infrastructure","resource_kind":"agent","capability":"clean_package_cache","entrypoint":"agentexec host_storage_cleanup","disposition":"lifecycle","lifecycle_executor":"hostStorageCleanupActionExecutor","approval_floor":"policy_or_admin","delivery":"committed_lifecycle_before_transport","verification":"required","rollback":"unsupported"}, {"id":"transport.agent.host-package-update","origin":"transport","resource_class":"customer_infrastructure","resource_kind":"agent","capability":"install_os_updates","entrypoint":"agentexec host_update","disposition":"lifecycle","lifecycle_executor":"hostUpdateActionExecutor","approval_floor":"policy_or_admin","delivery":"committed_lifecycle_before_transport","verification":"required","rollback":"task_10_truth_and_compensation","residual_owners":["task_10_truth_and_compensation"]}, {"id":"transport.agent.raw-command","origin":"transport","resource_class":"customer_infrastructure","resource_kind":"agent-target","capability":"executor_owned_command","entrypoint":"agentexec execute_command","disposition":"lifecycle","lifecycle_executor":"dockerContainerActionExecutor/proxmoxGuestActionExecutor","approval_floor":"admin","delivery":"committed_lifecycle_before_transport","verification":"required","rollback":"task_10_truth_and_compensation","residual_owners":["task_10_truth_and_compensation"]}, diff --git a/internal/mutationregistry/registry_generated.go b/internal/mutationregistry/registry_generated.go index 8c1629ad4..0d747de07 100644 --- a/internal/mutationregistry/registry_generated.go +++ b/internal/mutationregistry/registry_generated.go @@ -39,6 +39,7 @@ var generatedEntries = []Entry{ {ID: "transport.agent.deploy-cancel", Origin: Origin("transport"), ResourceClass: ResourceClass("pulse_administration"), ResourceKind: "agent-enrollment", Capability: "deploy_agent", Entrypoint: "agentexec deploy_cancel", Disposition: Disposition("administrative_exception"), LifecycleExecutor: "", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("administrative_transaction"), Verification: VerificationClass("administrative"), Rollback: RollbackClass("unsupported"), ResidualOwners: []string{"task_12_final_governance"}}, {ID: "transport.agent.deploy-install", Origin: Origin("transport"), ResourceClass: ResourceClass("pulse_administration"), ResourceKind: "agent-enrollment", Capability: "deploy_agent", Entrypoint: "agentexec deploy_install", Disposition: Disposition("administrative_exception"), LifecycleExecutor: "", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("administrative_transaction"), Verification: VerificationClass("administrative"), Rollback: RollbackClass("unsupported"), ResidualOwners: []string{"task_12_final_governance"}}, {ID: "transport.agent.deploy-preflight", Origin: Origin("transport"), ResourceClass: ResourceClass("pulse_administration"), ResourceKind: "agent-enrollment", Capability: "deploy_agent", Entrypoint: "agentexec deploy_preflight", Disposition: Disposition("administrative_exception"), LifecycleExecutor: "", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("administrative_transaction"), Verification: VerificationClass("administrative"), Rollback: RollbackClass("unsupported"), ResidualOwners: []string{"task_12_final_governance"}}, + {ID: "transport.agent.docker-container-lifecycle", Origin: Origin("transport"), ResourceClass: ResourceClass("customer_infrastructure"), ResourceKind: "app-container", Capability: "start_stop_restart", Entrypoint: "agentexec docker_container_lifecycle", Disposition: Disposition("lifecycle"), LifecycleExecutor: "dockerContainerActionExecutor", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("committed_lifecycle_before_transport"), Verification: VerificationClass("required"), Rollback: RollbackClass("task_10_truth_and_compensation"), ResidualOwners: []string{"task_10_truth_and_compensation"}}, {ID: "transport.agent.host-package-cache-cleanup", Origin: Origin("transport"), ResourceClass: ResourceClass("customer_infrastructure"), ResourceKind: "agent", Capability: "clean_package_cache", Entrypoint: "agentexec host_storage_cleanup", Disposition: Disposition("lifecycle"), LifecycleExecutor: "hostStorageCleanupActionExecutor", Approval: ApprovalFloor("policy_or_admin"), Delivery: DeliveryClass("committed_lifecycle_before_transport"), Verification: VerificationClass("required"), Rollback: RollbackClass("unsupported"), ResidualOwners: []string(nil)}, {ID: "transport.agent.host-package-update", Origin: Origin("transport"), ResourceClass: ResourceClass("customer_infrastructure"), ResourceKind: "agent", Capability: "install_os_updates", Entrypoint: "agentexec host_update", Disposition: Disposition("lifecycle"), LifecycleExecutor: "hostUpdateActionExecutor", Approval: ApprovalFloor("policy_or_admin"), Delivery: DeliveryClass("committed_lifecycle_before_transport"), Verification: VerificationClass("required"), Rollback: RollbackClass("task_10_truth_and_compensation"), ResidualOwners: []string{"task_10_truth_and_compensation"}}, {ID: "transport.agent.raw-command", Origin: Origin("transport"), ResourceClass: ResourceClass("customer_infrastructure"), ResourceKind: "agent-target", Capability: "executor_owned_command", Entrypoint: "agentexec execute_command", Disposition: Disposition("lifecycle"), LifecycleExecutor: "dockerContainerActionExecutor/proxmoxGuestActionExecutor", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("committed_lifecycle_before_transport"), Verification: VerificationClass("required"), Rollback: RollbackClass("task_10_truth_and_compensation"), ResidualOwners: []string{"task_10_truth_and_compensation"}}, diff --git a/internal/mutationregistry/runtime_surface_audit_test.go b/internal/mutationregistry/runtime_surface_audit_test.go index ce402e09f..fc598b7ad 100644 --- a/internal/mutationregistry/runtime_surface_audit_test.go +++ b/internal/mutationregistry/runtime_surface_audit_test.go @@ -1,6 +1,7 @@ package mutationregistry import ( + "fmt" "os" "path/filepath" "regexp" @@ -9,6 +10,37 @@ import ( "testing" ) +type routeClassification struct { + MutationID string +} + +var infrastructureRouteCatalog = map[string]routeClassification{ + "POST /api/actions/plan": {MutationID: "action.api.plan"}, + "GET /api/actions/pending": {}, + "GET /api/actions": {}, + "GET /api/actions/{id}": {}, + "POST /api/actions/{id}/decision": {MutationID: "action.api.decision"}, + "POST /api/actions/{id}/execute": {MutationID: "action.api.execute"}, + "/api/agents/docker/report": {}, + "/api/agents/docker/commands/": {}, + "/api/agents/docker/runtimes/": {MutationID: "docker.api.runtime-stop"}, + "/api/agents/docker/containers/update": {MutationID: "docker.api.update-container"}, + "/api/updates/check": {}, + "/api/updates/apply": {MutationID: "admin.pulse.self-update"}, + "/api/updates/rollback": {MutationID: "admin.pulse.self-update"}, + "/api/updates/status": {}, + "/api/updates/stream": {}, + "/api/updates/plan": {}, + "/api/updates/history": {}, + "/api/updates/history/entry": {}, + "/api/ai/run-command": {MutationID: "legacy.api.run-command"}, + "/api/ai/remediation/plans": {}, + "/api/ai/remediation/plan": {}, + "/api/ai/remediation/approve": {MutationID: "legacy.enterprise.remediation-approve"}, + "/api/ai/remediation/execute": {MutationID: "legacy.enterprise.remediation-execute"}, + "/api/ai/remediation/rollback": {MutationID: "legacy.enterprise.remediation-rollback"}, +} + // TestInfrastructureAPIRoutesResolveToRegistry scans the real router sources, // not a duplicate ID inventory. Its scope is deliberately limited to the // infrastructure mutation namespaces below; settings/metadata CRUD is outside @@ -20,31 +52,7 @@ func TestInfrastructureAPIRoutesResolveToRegistry(t *testing.T) { "internal/api/router_routes_registration.go", "internal/api/router_routes_ai_relay.go", } - catalog := map[string]string{ - "/api/actions/plan": "action.api.plan", - "/api/actions/pending": "", - "/api/actions/{id}/decision": "action.api.decision", - "/api/actions/{id}/execute": "action.api.execute", - "/api/agents/docker/report": "", - "/api/agents/docker/commands/": "", - "/api/agents/docker/runtimes/": "docker.api.runtime-stop", - "/api/agents/docker/containers/update": "docker.api.update-container", - "/api/updates/check": "", - "/api/updates/apply": "admin.pulse.self-update", - "/api/updates/rollback": "admin.pulse.self-update", - "/api/updates/status": "", - "/api/updates/stream": "", - "/api/updates/plan": "", - "/api/updates/history": "", - "/api/updates/history/entry": "", - "/api/ai/run-command": "legacy.api.run-command", - "/api/ai/remediation/plans": "", - "/api/ai/remediation/plan": "", - "/api/ai/remediation/approve": "legacy.enterprise.remediation-approve", - "/api/ai/remediation/execute": "legacy.enterprise.remediation-execute", - "/api/ai/remediation/rollback": "legacy.enterprise.remediation-rollback", - } - routeRE := regexp.MustCompile(`"(?:GET |POST |PUT |DELETE )?(/api/[^"]+)"`) + routeRE := regexp.MustCompile(`"(?:(GET|POST|PUT|DELETE) )?(/api/[^"]+)"`) found := map[string]bool{} for _, relative := range files { data, err := os.ReadFile(filepath.Join(root, relative)) @@ -52,26 +60,27 @@ func TestInfrastructureAPIRoutesResolveToRegistry(t *testing.T) { t.Fatal(err) } for _, match := range routeRE.FindAllStringSubmatch(string(data), -1) { - route := match[1] + method, route := match[1], match[2] if !auditedInfrastructurePrefix(route) { continue } - found[route] = true - id, classified := catalog[route] + key := routeCatalogKey(method, route) + found[key] = true + classification, classified := infrastructureRouteCatalog[key] if !classified { - t.Errorf("runtime infrastructure route %q is unclassified", route) + t.Errorf("runtime infrastructure route %q is unclassified", key) continue } - if id != "" { - if err := AuditRuntimeCandidates([]RuntimeCandidate{{Surface: "http:" + route, MutationID: id}}); err != nil { + if classification.MutationID != "" { + if err := AuditRuntimeCandidates([]RuntimeCandidate{{Surface: "http:" + key, MutationID: classification.MutationID}}); err != nil { t.Error(err) } } } } - for route := range catalog { - if !found[route] { - t.Errorf("classified route %q is no longer registered", route) + for key := range infrastructureRouteCatalog { + if !found[key] { + t.Errorf("classified route %q is no longer registered", key) } } // The shared Docker gateway contains a second mutation sub-route. @@ -84,7 +93,17 @@ func TestInfrastructureAPIRoutesResolveToRegistry(t *testing.T) { } } +func routeCatalogKey(method, route string) string { + if strings.TrimSpace(method) == "" { + return route + } + return method + " " + route +} + func auditedInfrastructurePrefix(route string) bool { + if route == "/api/actions" { + return true + } for _, prefix := range []string{"/api/actions/", "/api/agents/docker/", "/api/updates/", "/api/ai/remediation/", "/api/ai/run-command"} { if strings.HasPrefix(route, prefix) { return true @@ -93,32 +112,90 @@ func auditedInfrastructurePrefix(route string) bool { return false } +func TestActionRouteMethodAuthorityIsExactAndLookalikesFailClosed(t *testing.T) { + tests := []struct { + method, route, mutationID string + classified bool + }{ + {method: "GET", route: "/api/actions/{id}", classified: true}, + {method: "POST", route: "/api/actions/{id}/execute", mutationID: "action.api.execute", classified: true}, + {method: "DELETE", route: "/api/actions/{id}"}, + {method: "POST", route: "/api/actions/{id}"}, + {method: "GET", route: "/api/actions/{id}/execute"}, + {method: "POST", route: "/api/actions/{id}/execute-copy"}, + } + for _, tt := range tests { + key := routeCatalogKey(tt.method, tt.route) + classification, ok := infrastructureRouteCatalog[key] + if ok != tt.classified { + t.Errorf("route %q classified=%v, want %v", key, ok, tt.classified) + continue + } + if ok && classification.MutationID != tt.mutationID { + t.Errorf("route %q mutation=%q, want %q", key, classification.MutationID, tt.mutationID) + } + } +} + +type transportClassification struct { + Role TransportRole + MutationID string + DurableAuthorityID string +} + func TestTransportCommandCatalogsResolveToRegistry(t *testing.T) { root := repoRoot(t) docker := scanNamedStringConstants(t, filepath.Join(root, "internal/monitoring/docker_commands.go"), `DockerCommandType\w+`) - dockerCatalog := map[string]string{ - "DockerCommandTypeStop": "transport.docker.runtime-stop", - "DockerCommandTypeUpdateContainer": "transport.docker.update-container", - "DockerCommandTypeUpdateAll": "transport.docker.update-all", - "DockerCommandTypeCheckUpdates": "", + dockerCatalog := map[string]transportClassification{ + "DockerCommandTypeStop": {Role: TransportRoleMutationRequest, MutationID: "transport.docker.runtime-stop"}, + "DockerCommandTypeUpdateContainer": {Role: TransportRoleMutationRequest, MutationID: "transport.docker.update-container"}, + "DockerCommandTypeUpdateAll": {Role: TransportRoleMutationRequest, MutationID: "transport.docker.update-all"}, + "DockerCommandTypeCheckUpdates": {Role: TransportRoleProtocol}, } - auditConstantCatalog(t, docker, dockerCatalog, nil) + auditConstantCatalog(t, docker, dockerCatalog) agent := scanNamedStringConstants(t, filepath.Join(root, "internal/agentexec/types.go"), `MsgType\w+`) - agentCatalog := map[string]string{ - "MsgTypeAgentRegister": "", "MsgTypeAgentPing": "", "MsgTypeCommandResult": "", - "MsgTypeHostStorageCleanupResult": "", "MsgTypeHostUpdateResult": "", "MsgTypeRegistered": "", "MsgTypePong": "", - "MsgTypeExecuteCmd": "transport.agent.raw-command", "MsgTypeHostStorageCleanup": "transport.agent.host-package-cache-cleanup", - "MsgTypeReadFile": "", "MsgTypeHostUpdate": "transport.agent.host-package-update", - "MsgTypeDeployPreflight": "transport.agent.deploy-preflight", "MsgTypeDeployInstall": "transport.agent.deploy-install", - "MsgTypeDeployCancelJob": "transport.agent.deploy-cancel", "MsgTypeDeployProgress": "", + agentCatalog := map[string]transportClassification{ + "MsgTypeAgentRegister": {Role: TransportRoleProtocol}, "MsgTypeAgentPing": {Role: TransportRoleProtocol}, + "MsgTypeCommandResult": {Role: TransportRoleOperationResult}, + "MsgTypeHostStorageCleanupResult": {Role: TransportRoleOperationResult}, "MsgTypeHostUpdateResult": {Role: TransportRoleOperationResult}, + "MsgTypeDockerContainerLifecycleResult": {Role: TransportRoleOperationResult}, + "MsgTypeOperationQueryResult": {Role: TransportRoleOperationReceipt}, + "MsgTypeRegistered": {Role: TransportRoleProtocol}, "MsgTypePong": {Role: TransportRoleProtocol}, + "MsgTypeExecuteCmd": {Role: TransportRoleMutationRequest, MutationID: "transport.agent.raw-command", DurableAuthorityID: "assistant.resource-action"}, + "MsgTypeHostStorageCleanup": {Role: TransportRoleMutationRequest, MutationID: "transport.agent.host-package-cache-cleanup", DurableAuthorityID: "resource.host.package-cache-cleanup"}, + "MsgTypeReadFile": {Role: TransportRoleProtocol}, + "MsgTypeHostUpdate": {Role: TransportRoleMutationRequest, MutationID: "transport.agent.host-package-update", DurableAuthorityID: "resource.host.package-update"}, + "MsgTypeDockerContainerLifecycle": {Role: TransportRoleMutationRequest, MutationID: "transport.agent.docker-container-lifecycle", DurableAuthorityID: "resource.docker.container-lifecycle"}, + "MsgTypeOperationQuery": {Role: TransportRoleOperationQuery}, + "MsgTypeDeployPreflight": {Role: TransportRoleAdministrativeRequest, MutationID: "transport.agent.deploy-preflight"}, + "MsgTypeDeployInstall": {Role: TransportRoleAdministrativeRequest, MutationID: "transport.agent.deploy-install"}, + "MsgTypeDeployCancelJob": {Role: TransportRoleAdministrativeRequest, MutationID: "transport.agent.deploy-cancel"}, + "MsgTypeDeployProgress": {Role: TransportRoleOperationResult}, } - authority := map[string]string{ - "MsgTypeExecuteCmd": "assistant.resource-action", - "MsgTypeHostStorageCleanup": "resource.host.package-cache-cleanup", - "MsgTypeHostUpdate": "resource.host.package-update", + auditConstantCatalog(t, agent, agentCatalog) +} + +func TestNonAdmittingTransportMessagesCannotCarryDispatchAuthority(t *testing.T) { + for _, role := range []TransportRole{TransportRoleOperationQuery, TransportRoleOperationResult, TransportRoleOperationReceipt, TransportRoleProtocol} { + err := AuditTransportSurfaces([]TransportSurface{{ + Name: "lookalike", WireValue: "docker_container_lifecycle_result_retry", Role: role, + MutationID: "transport.agent.docker-container-lifecycle", DurableAuthorityID: "resource.docker.container-lifecycle", + }}) + if err == nil { + t.Errorf("non-admitting role %q accepted dispatch authority", role) + } + } +} + +func TestUnknownTransportLookalikeFailsClosed(t *testing.T) { + actual := map[string]string{"MsgTypeDockerContainerLifecycle": "docker_container_lifecycle", "MsgTypeDockerContainerLifecycleRetry": "docker_container_lifecycle_retry"} + catalog := map[string]transportClassification{ + "MsgTypeDockerContainerLifecycle": {Role: TransportRoleMutationRequest, MutationID: "transport.agent.docker-container-lifecycle", DurableAuthorityID: "resource.docker.container-lifecycle"}, + } + if unknown := unclassifiedTransportConstants(actual, catalog); len(unknown) != 1 || unknown[0] != `MsgTypeDockerContainerLifecycleRetry="docker_container_lifecycle_retry"` { + t.Fatalf("unknown lookalike classification = %v", unknown) } - auditConstantCatalog(t, agent, agentCatalog, authority) } func TestPatrolJobRegistrationResolvesToRegistry(t *testing.T) { @@ -147,21 +224,19 @@ func scanNamedStringConstants(t *testing.T, path, namePattern string) map[string return out } -func auditConstantCatalog(t *testing.T, actual, catalog, authority map[string]string) { +func auditConstantCatalog(t *testing.T, actual map[string]string, catalog map[string]transportClassification) { t.Helper() + var surfaces []TransportSurface for name, value := range actual { - id, ok := catalog[name] + classification, ok := catalog[name] if !ok { t.Errorf("transport command %s=%q is unclassified", name, value) continue } - if id == "" { - continue - } - candidate := RuntimeCandidate{Surface: "transport:" + value, MutationID: id, Transport: true, DurableAuthorityID: authority[name]} - if err := AuditRuntimeCandidates([]RuntimeCandidate{candidate}); err != nil { - t.Error(err) - } + surfaces = append(surfaces, TransportSurface{Name: name, WireValue: value, Role: classification.Role, MutationID: classification.MutationID, DurableAuthorityID: classification.DurableAuthorityID}) + } + if err := AuditTransportSurfaces(surfaces); err != nil { + t.Error(err) } var stale []string for name := range catalog { @@ -174,3 +249,14 @@ func auditConstantCatalog(t *testing.T, actual, catalog, authority map[string]st t.Errorf("classified transport constants no longer exist: %v", stale) } } + +func unclassifiedTransportConstants(actual map[string]string, catalog map[string]transportClassification) []string { + var unknown []string + for name, value := range actual { + if _, ok := catalog[name]; !ok { + unknown = append(unknown, fmt.Sprintf(`%s=%q`, name, value)) + } + } + sort.Strings(unknown) + return unknown +}