From cd5e01be20893adffd6427542fac45c33d17b150 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sun, 2 Aug 2026 19:08:57 +0300 Subject: [PATCH 1/6] web4: WIP enterprise-control daemon wiring + governed pilotctl + IPC unbind + test fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preservation snapshot of uncommitted working-tree work (repo survey 2026-08-02). WIP branch — do NOT push directly; split into reviewed PRs first. Build scratch excluded via .gitignore. Co-Authored-By: Claude Fable 5 --- .github/workflows/deploy-catalogue-site.yml | 57 + .../apps/io.pilot.smolmachines/metadata.json | 66 + cmd/daemon/main.go | 289 ++- cmd/daemon/security_profile.go | 104 + cmd/daemon/security_profile_test.go | 101 + cmd/pilotctl/appstore.go | 5 + cmd/pilotctl/enterprise.go | 610 +++++ cmd/pilotctl/enterprise_test.go | 467 ++++ cmd/pilotctl/executable_platform.go | 98 + cmd/pilotctl/executable_platform_test.go | 58 + cmd/pilotctl/governed_transport.go | 333 +++ cmd/pilotctl/governed_transport_test.go | 199 ++ cmd/pilotctl/main.go | 169 +- cmd/pilotctl/skills.go | 9 +- cmd/pilotctl/zz_more_cmds_test.go | 1 + internal/enterprisecontrol/action_hook.go | 447 ++++ internal/enterprisecontrol/control.go | 2164 +++++++++++++++++ internal/enterprisecontrol/control_test.go | 1746 +++++++++++++ internal/enterprisecontrol/fleet_state.go | 741 ++++++ .../enterprisecontrol/fleet_state_test.go | 131 + internal/enterprisecontrol/mandates.go | 78 + pkg/daemon/ipc.go | 36 + pkg/daemon/zz_ipc_unbind_test.go | 45 + tests/zz_hostname_test.go | 2 + tests/zz_identity_test.go | 38 +- tests/zz_integration_test.go | 53 +- tests/zz_key_lifecycle_test.go | 1 + tests/zz_network_test.go | 14 +- tests/zz_registry_hardening_test.go | 6 +- tests/zz_security_phase2_test.go | 33 +- tests/zz_test_beacon_helpers_test.go | 6 +- 31 files changed, 8022 insertions(+), 85 deletions(-) create mode 100644 .github/workflows/deploy-catalogue-site.yml create mode 100644 catalogue/apps/io.pilot.smolmachines/metadata.json create mode 100644 cmd/daemon/security_profile.go create mode 100644 cmd/daemon/security_profile_test.go create mode 100644 cmd/pilotctl/enterprise.go create mode 100644 cmd/pilotctl/enterprise_test.go create mode 100644 cmd/pilotctl/executable_platform.go create mode 100644 cmd/pilotctl/executable_platform_test.go create mode 100644 cmd/pilotctl/governed_transport.go create mode 100644 cmd/pilotctl/governed_transport_test.go create mode 100644 internal/enterprisecontrol/action_hook.go create mode 100644 internal/enterprisecontrol/control.go create mode 100644 internal/enterprisecontrol/control_test.go create mode 100644 internal/enterprisecontrol/fleet_state.go create mode 100644 internal/enterprisecontrol/fleet_state_test.go create mode 100644 internal/enterprisecontrol/mandates.go create mode 100644 pkg/daemon/zz_ipc_unbind_test.go diff --git a/.github/workflows/deploy-catalogue-site.yml b/.github/workflows/deploy-catalogue-site.yml new file mode 100644 index 00000000..8b86cfcd --- /dev/null +++ b/.github/workflows/deploy-catalogue-site.yml @@ -0,0 +1,57 @@ +name: Deploy app catalogue site + +# Publishes the static app-store catalogue site to GitHub Pages. The page +# renders the live catalogue.json (copied alongside it, so the fetch is +# same-origin), and redeploys whenever the catalogue or the site changes. + +on: + push: + branches: [main] + paths: + - "catalogue/**" + - ".github/workflows/deploy-catalogue-site.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment; let an in-progress run finish. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Assemble site + run: | + set -euo pipefail + mkdir -p _site + cp catalogue/site/index.html _site/index.html + # Copy the catalogue + detached signature so the page fetches + # them same-origin (no CORS dependency on raw.githubusercontent). + cp catalogue/catalogue.json _site/catalogue.json + cp catalogue/catalogue.json.sig _site/catalogue.json.sig + # Fail loudly if the catalogue is missing/empty. + test -s _site/catalogue.json + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/catalogue/apps/io.pilot.smolmachines/metadata.json b/catalogue/apps/io.pilot.smolmachines/metadata.json new file mode 100644 index 00000000..522394e1 --- /dev/null +++ b/catalogue/apps/io.pilot.smolmachines/metadata.json @@ -0,0 +1,66 @@ +{ + "schema_version": 1, + "id": "io.pilot.smolmachines", + "display_name": "Smol Machines", + "tagline": "Fast, hardware-isolated microVMs on demand", + "description_md": "Smol Machines — the app-store front door for the smolmachines VM engine. It lets an agent spin up fast, hardware-isolated Linux microVMs on demand (sub-second boot, real hypervisor isolation — not shared-kernel containers), then run workloads in a disposable sandbox. Free to use. Portable .smolmachine artifacts run identically on macOS and Linux, locally or in the cloud.\n\nUse it to:\n- Run untrusted or AI-generated code safely, with networking off by default\n- Give an agent a real Linux shell — a stateful, isolated execution backend\n- Automate headless browsers (GPU-accelerated) for scraping, screenshots, and web tasks\n- Run GPU/compute jobs via Vulkan with container-like speed\n- Spin up disposable dev sandboxes — a clean VM per task, torn down after\n- Keep persistent dev VMs — installed packages survive restarts\n- Run CI-style jobs — build, test, lint in clean environments\n- Fan out parallel ephemeral workers thanks to sub-second boot\n- Analyze malware / suspicious files in a throwaway environment\n- Build once, run anywhere — same artifact local, cloud, or self-hosted\n\nDiscover the live method surface at runtime with smolmachines.help, which lists each method's parameters and latency class.", + "vendor": { + "name": "smol machines", + "url": "https://smolmachines.com", + "publisher_pubkey": "ed25519:3QJm6H6OdjtfrF+Es1lrRjfFmdtq2tGvVSWxia63vcI=" + }, + "homepage": "https://smolmachines.com", + "source_url": "https://github.com/smol-machines/smolvm", + "license": "Apache-2.0", + "categories": [ + "dev", + "virtualization", + "security" + ], + "keywords": [ + "microvm", + "sandbox", + "vm", + "isolation", + "gpu", + "ci" + ], + "size": { + "bundle_bytes": 5346146, + "installed_bytes": 9601119 + }, + "compat": { + "min_pilot_version": "1.0.0", + "runtimes": [ + "go" + ] + }, + "methods": [ + { + "name": "smolmachines.exec", + "summary": "Run any smolvm subcommand in a fast, hardware-isolated Linux microVM. Payload is {\"args\":[...]} — the verbatim smolvm argv. Command surface: `machine run` (ephemeral VM, one-off command), `machine create|start|exec|stop|delete|shell|status|ls|cp|update|monitor|prune` (persistent VMs; `exec` persists filesystem changes), `pack create|run` (portable .smolmachine artifacts), `serve` (HTTP API), `config`. Key flags: `--net` (networking is OFF by default), `--image `, `-v HOST:GUEST`, `-p HOST:GUEST`, `--gpu`, `--ssh-agent`, `--secret-env GUEST=HOST`. Example args: [\"machine\",\"run\",\"--net\",\"--image\",\"alpine\",\"--\",\"sh\",\"-c\",\"echo hi\"]. Not supported over IPC: interactive sessions (-it / `machine shell`) and long-running `serve`." + }, + { + "name": "smolmachines.help", + "summary": "Discovery: every method with params, kind, and latency class." + } + ], + "changelog": [ + { + "version": "1.2.0", + "notes": [ + "Released v1.2.0" + ] + } + ], + "links": [ + { + "label": "Source", + "url": "https://github.com/smol-machines/smolvm" + }, + { + "label": "Website", + "url": "https://smolmachines.com" + } + ] +} diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 9d5b3d23..2a3f511a 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -18,9 +18,11 @@ import ( "syscall" "time" + "github.com/pilot-protocol/common/authority" "github.com/pilot-protocol/common/config" "github.com/pilot-protocol/common/driver" "github.com/pilot-protocol/common/logging" + "github.com/pilot-protocol/pilotprotocol/internal/enterprisecontrol" "github.com/pilot-protocol/pilotprotocol/internal/motd" "github.com/pilot-protocol/pilotprotocol/pkg/daemon" @@ -44,8 +46,11 @@ import ( var version = "dev" +var remoteLifecycleRequests = make(chan string, 1) + func main() { configPath := flag.String("config", "", "path to config file (JSON)") + securityProfile := flag.String("security-profile", envString("PILOT_SECURITY_PROFILE", securityProfileCompatible), "locked security profile: compatible or enterprise") registryDefault := "34.71.57.205:9000" registryFromEnv := false if v := os.Getenv("PILOT_REGISTRY"); v != "" { @@ -92,6 +97,7 @@ func main() { noDataExchange := flag.Bool("no-dataexchange", false, "disable built-in data exchange service (port 1001)") dataExchangeB64 := flag.Bool("dataexchange-b64", false, "write inbox message payloads as a raw base64 `data_b64` field in place of the UTF-8 `data` field — needed only for binary payloads (e.g. zlib-compressed envelopes)") noEventStream := flag.Bool("no-eventstream", false, "disable built-in event stream service (port 1002)") + enterpriseControlPath := flag.String("enterprise-control", "", "path to signed enterprise control attachment (root pin, trust bundle, policy bundle, and governed transport rules)") noSkillinject := flag.Bool("no-skillinject", false, "disable built-in skill-injection service (agent context injection). Env: PILOT_NO_SKILLINJECT=1.") webhookURL := flag.String("webhook", "", "HTTP(S) endpoint for event notifications (empty = disabled)") webhookSecret := flag.String("webhook-secret", "", "HMAC-SHA256 pre-shared secret for webhook payload signing (empty = no signature). Env: PILOT_WEBHOOK_SECRET.") @@ -181,6 +187,32 @@ func main() { } } + profileOptions := daemonSecurityOptions{ + RegistryAddr: *registryAddr, + RegistryTLS: *registryTLS, + RegistryFingerprint: *registryFingerprint, + RegistryTrust: *registryTrust, + Encrypt: *encrypt, + StrictDataPlaneTrust: *strictDataplaneTrust || os.Getenv("PILOT_STRICT_DATAPLANE_TRUST") == "1", + IdentityPath: *identityPath, + TrustAutoApprove: *trustAutoApprove, + DisableSkillinject: *noSkillinject || os.Getenv("PILOT_NO_SKILLINJECT") == "1", + SkillinjectVerificationKeyFound: os.Getenv("PILOT_SKILLINJECT_MANIFEST_PUBKEY") != "" || os.Getenv("PILOT_SKILLINJECT_PUBKEY") != "", + MOTDFeedURL: *motdFeedURL, + WebhookURL: *webhookURL, + EnterpriseControlPath: *enterpriseControlPath, + DisableDataExchange: *noDataExchange, + DisableEventStream: *noEventStream, + } + if err := applyDaemonSecurityProfile(*securityProfile, &profileOptions); err != nil { + log.Fatalf("security profile: %v", err) + } + *registryTLS = profileOptions.RegistryTLS + *encrypt = profileOptions.Encrypt + *strictDataplaneTrust = profileOptions.StrictDataPlaneTrust + *noSkillinject = profileOptions.DisableSkillinject + *motdFeedURL = profileOptions.MOTDFeedURL + logging.Setup(*logLevel, *logFormat) // Sandbox: validate all configured file paths are under the confinement @@ -214,6 +246,21 @@ func main() { checkSandbox("config", *configPath) checkSandbox("identity", *identityPath) checkSandbox("socket", *socketPath) + checkSandbox("enterprise-control", *enterpriseControlPath) + } + + var enterpriseControls *enterprisecontrol.Runtime + if *enterpriseControlPath != "" { + var err error + enterpriseControls, err = enterprisecontrol.Load(*enterpriseControlPath) + if err != nil { + log.Fatalf("enterprise control: %v", err) + } + } + if strings.EqualFold(strings.TrimSpace(*securityProfile), securityProfileEnterprise) { + if err := enterpriseControls.RequireEnabledServiceGates(!*noDataExchange, !*noEventStream); err != nil { + log.Fatalf("enterprise control: %v", err) + } } if registryFromEnv { @@ -319,15 +366,23 @@ func main() { } if !*noDataExchange { - if err := rt.Register(dataexchange.NewService(dataexchange.ServiceConfig{ + dataExchangeConfig := dataexchange.ServiceConfig{ IncludeBase64: *dataExchangeB64, - })); err != nil { + } + if err := enterpriseControls.ApplyDataExchange(&dataExchangeConfig); err != nil { + log.Fatalf("configure dataexchange enterprise control: %v", err) + } + if err := rt.Register(dataexchange.NewService(dataExchangeConfig)); err != nil { log.Fatalf("register dataexchange: %v", err) } } if !*noEventStream { - if err := rt.Register(eventstream.NewService()); err != nil { + eventStreamService := eventstream.NewService() + if err := enterpriseControls.ApplyEventStream(eventStreamService); err != nil { + log.Fatalf("configure eventstream enterprise control: %v", err) + } + if err := rt.Register(eventStreamService); err != nil { log.Fatalf("register eventstream: %v", err) } } @@ -340,6 +395,9 @@ func main() { // Manual trust-handshake (port 444) — extracted from pkg/daemon in T3.3. hsSvc := handshake.NewService(runtime.NewHandshakeRuntime(dapi)) + if actionHook := enterpriseControls.ActionHook(); actionHook != nil { + hsSvc.Manager().SetActionHook(actionHook) + } if err := rt.Register(hsSvc); err != nil { log.Fatalf("register handshake: %v", err) } @@ -452,10 +510,110 @@ func main() { log.Fatalf("daemon start: %v", err) } - // Wait for signal + rolloutRefreshCtx, rolloutRefreshCancel := context.WithCancel(context.Background()) + if enterpriseControls.HasRollout() { + if err := enterpriseControls.RefreshRollout(rolloutRefreshCtx); err != nil { + slog.Warn("enterprise rollout refresh failed; retaining current local policy", "err", err) + } + go func() { + ticker := time.NewTicker(enterpriseControls.RolloutInterval()) + defer ticker.Stop() + for { + select { + case <-rolloutRefreshCtx.Done(): + return + case <-ticker.C: + if err := enterpriseControls.RefreshRollout(rolloutRefreshCtx); err != nil { + slog.Warn("enterprise rollout refresh failed; retaining current local policy", "err", err) + } + } + } + }() + } + + fleetControlCtx, fleetControlCancel := context.WithCancel(context.Background()) + if enterpriseControls.HasFleetControl() { + synchronizeFleetControl(fleetControlCtx, enterpriseControls, d) + go func() { + ticker := time.NewTicker(enterpriseControls.FleetReportInterval()) + defer ticker.Stop() + for { + select { + case <-fleetControlCtx.Done(): + return + case <-ticker.C: + synchronizeFleetControl(fleetControlCtx, enterpriseControls, d) + } + } + }() + } + if enterpriseControls.HasFleetStateSync() { + synchronizeFleetState(fleetControlCtx, enterpriseControls) + go func() { + ticker := time.NewTicker(enterpriseControls.FleetStateSyncInterval()) + defer ticker.Stop() + for { + select { + case <-fleetControlCtx.Done(): + return + case <-ticker.C: + synchronizeFleetState(fleetControlCtx, enterpriseControls) + } + } + }() + } + + receiptExportCtx, receiptExportCancel := context.WithCancel(context.Background()) + if enterpriseControls.HasReceiptExport() { + if err := enterpriseControls.ExportReceiptsOnce(receiptExportCtx); err != nil { + slog.Warn("enterprise receipt export failed; local evidence remains durable", "err", err) + } + go func() { + ticker := time.NewTicker(enterpriseControls.ReceiptExportInterval()) + defer ticker.Stop() + for { + select { + case <-receiptExportCtx.Done(): + return + case <-ticker.C: + if err := enterpriseControls.ExportReceiptsOnce(receiptExportCtx); err != nil { + slog.Warn("enterprise receipt export failed; local evidence remains durable", "err", err) + } + } + } + }() + } + + // SIGHUP advances only the already-pinned signed authority state. It does + // not reload daemon flags, root pins, or resource mappings, which remain a + // deliberate restart-time administrative change. sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) - <-sig + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + restartRequested := false +shutdownLoop: + for { + select { + case received := <-sig: + if received == syscall.SIGHUP { + if enterpriseControls == nil { + slog.Warn("enterprise control reload ignored: no attachment is configured") + } else if err := enterpriseControls.Reload(); err != nil { + slog.Error("enterprise control reload rejected; keeping current signed state", "err", err) + } else { + slog.Info("enterprise control reloaded") + } + continue + } + break shutdownLoop + case lifecycle := <-remoteLifecycleRequests: + restartRequested = lifecycle == "restart" + break shutdownLoop + } + } + signal.Stop(sig) + rolloutRefreshCancel() + receiptExportCancel() + fleetControlCancel() // Order matters: Daemon.Stop publishes daemon.shutting_down to the // bus before tearing down ports/IPC/tunnels. Plugins (notably @@ -471,6 +629,125 @@ func main() { slog.Warn("plugin shutdown error", "err", err) } stopCancel() + if restartRequested { + executable, err := os.Executable() + if err != nil { + slog.Error("resolve daemon executable for remote restart", "err", err) + return + } + slog.Info("restarting daemon after graceful shutdown") + if err := syscall.Exec(executable, os.Args, os.Environ()); err != nil { + slog.Error("remote daemon restart failed", "err", err) + } + } +} + +// synchronizeFleetControl reports bounded local health and runs only the +// fixed, authority-signed maintenance commands. It intentionally has no +// generic process execution, file access, shell, or network-dial capability. +func synchronizeFleetControl(ctx context.Context, controls *enterprisecontrol.Runtime, daemonInstance *daemon.Daemon) { + health := daemonInstance.HealthSnapshot() + info := daemonInstance.Info() + reconciliation, reconciliationErr := controls.ReconcileFleetControl(ctx, info.Version) + if reconciliationErr != nil { + slog.Warn("fleet desired-state reconciliation failed", "err", reconciliationErr) + } else if reconciliation.Found && reconciliation.Status != "applied" { + slog.Warn("fleet desired state requires attention", "revision", reconciliation.Control.Revision, "detail", reconciliation.DetailCode) + } + if reconciliation.Found { + if err := controls.ReportFleetControlAcknowledgement(ctx, reconciliation, info.Version); err != nil { + slog.Warn("fleet desired-state acknowledgement failed", "revision", reconciliation.Control.Revision, "err", err) + } + } + status := enterprisecontrol.FleetNodeStatus{ + NodeID: info.NodeID, + AgentVersion: info.Version, + UptimeSeconds: uint64(health.Uptime.Seconds()), + Connections: uint32(health.Connections), + Peers: uint32(health.Peers), + EncryptedPeers: uint32(health.EncryptedPeers), + BytesSent: health.BytesSent, + BytesReceived: health.BytesRecv, + PolicyRevision: controls.CurrentPolicyRevision(ctx), + } + if err := controls.ReportFleetStatus(ctx, status); err != nil { + slog.Warn("fleet status report failed", "err", err) + } + commands, err := controls.FleetCommands(ctx) + if err != nil { + slog.Warn("fleet command poll failed", "err", err) + return + } + for _, command := range commands { + outcome, detail := "succeeded", "" + lifecycle := "" + switch command.Kind { + case authority.FleetCommandRefreshPolicy: + if err := controls.RefreshRollout(ctx); err != nil { + outcome, detail = "failed", "rollout_refresh_failed" + } + case authority.FleetCommandExportReceipts: + if !controls.HasReceiptExport() { + outcome, detail = "rejected", "receipt_export_unconfigured" + } else if err := controls.ExportReceiptsOnce(ctx); err != nil { + outcome, detail = "failed", "receipt_export_failed" + } + case authority.FleetCommandReloadControl: + if err := controls.Reload(); err != nil { + outcome, detail = "failed", "control_reload_failed" + } + case authority.FleetCommandSyncState: + if !controls.HasFleetStateSync() { + outcome, detail = "rejected", "state_sync_unconfigured" + } else if _, err := controls.SyncFleetState(ctx); err != nil { + outcome, detail = "failed", "state_sync_failed" + } + case authority.FleetCommandDiagnostics: + // The signed health report above is the bounded diagnostic + // payload. Include the .pilot mirror when that optional channel + // is enabled, without returning logs or environment values. + if controls.HasFleetStateSync() { + if _, err := controls.SyncFleetState(ctx); err != nil { + outcome, detail = "failed", "diagnostics_sync_failed" + } + } + case authority.FleetCommandRestartRuntime: + lifecycle = "restart" + case authority.FleetCommandShutdownRuntime: + lifecycle = "shutdown" + default: + outcome, detail = "rejected", "command_not_allowlisted" + } + if err := controls.ReportFleetCommandResult(ctx, command.ID, outcome, detail); err != nil { + slog.Warn("fleet command result report failed", "command_id", command.ID, "err", err) + continue + } + if outcome == "succeeded" && lifecycle != "" { + select { + case remoteLifecycleRequests <- lifecycle: + default: + slog.Warn("fleet lifecycle request already pending", "command_id", command.ID) + } + } + } +} + +func synchronizeFleetState(ctx context.Context, controls *enterprisecontrol.Runtime) { + result, err := controls.SyncFleetState(ctx) + if err != nil { + slog.Warn("fleet .pilot state synchronization failed", "err", err) + return + } + if result.AppliedMutations > 0 || result.RejectedMutations > 0 { + slog.Info("fleet .pilot state synchronized", "revision", result.Revision, "entries", result.Entries, "applied_mutations", result.AppliedMutations, "rejected_mutations", result.RejectedMutations) + } +} + +func envString(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback } // webhookManagerAdapter bridges *webhook.Service to the daemon's diff --git a/cmd/daemon/security_profile.go b/cmd/daemon/security_profile.go new file mode 100644 index 00000000..8dfb68f8 --- /dev/null +++ b/cmd/daemon/security_profile.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +const ( + securityProfileCompatible = "compatible" + securityProfileEnterprise = "enterprise" +) + +type daemonSecurityOptions struct { + RegistryAddr string + RegistryTLS bool + RegistryFingerprint string + RegistryTrust string + Encrypt bool + StrictDataPlaneTrust bool + IdentityPath string + TrustAutoApprove bool + DisableSkillinject bool + SkillinjectVerificationKeyFound bool + MOTDFeedURL string + WebhookURL string + EnterpriseControlPath string + DisableDataExchange bool + DisableEventStream bool +} + +func applyDaemonSecurityProfile(name string, o *daemonSecurityOptions) error { + if o == nil { + return fmt.Errorf("security profile options are nil") + } + switch strings.ToLower(strings.TrimSpace(name)) { + case "", securityProfileCompatible: + return nil + case securityProfileEnterprise: + o.Encrypt = true + o.RegistryTLS = true + o.StrictDataPlaneTrust = true + o.MOTDFeedURL = "" // the current feed has no signed-content format + if !o.SkillinjectVerificationKeyFound { + o.DisableSkillinject = true + } + if strings.TrimSpace(o.RegistryAddr) == "" { + return fmt.Errorf("enterprise profile requires an explicit registry address") + } + if strings.TrimSpace(o.IdentityPath) == "" { + return fmt.Errorf("enterprise profile requires a persistent -identity path") + } + if o.TrustAutoApprove { + return fmt.Errorf("enterprise profile forbids -trust-auto-approve") + } + switch strings.ToLower(strings.TrimSpace(o.RegistryTrust)) { + case "pinned": + if strings.TrimSpace(o.RegistryFingerprint) == "" { + return fmt.Errorf("enterprise profile with pinned registry trust requires -registry-fingerprint") + } + case "system": + default: + return fmt.Errorf("enterprise profile requires registry trust to be pinned or system, got %q", o.RegistryTrust) + } + if o.WebhookURL != "" { + if err := requireSecureOrLoopbackURL(o.WebhookURL); err != nil { + return fmt.Errorf("enterprise webhook: %w", err) + } + } + if !o.DisableDataExchange || !o.DisableEventStream { + if strings.TrimSpace(o.EnterpriseControlPath) == "" { + return fmt.Errorf("enterprise profile requires -enterprise-control while data exchange or event stream is enabled") + } + } + return nil + default: + return fmt.Errorf("unknown security profile %q (want %q or %q)", name, securityProfileCompatible, securityProfileEnterprise) + } +} + +func requireSecureOrLoopbackURL(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Hostname() == "" { + return fmt.Errorf("invalid URL %q", raw) + } + if strings.EqualFold(u.Scheme, "https") { + return nil + } + if !strings.EqualFold(u.Scheme, "http") { + return fmt.Errorf("URL must use HTTPS or loopback HTTP") + } + host := u.Hostname() + if strings.EqualFold(host, "localhost") { + return nil + } + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return fmt.Errorf("HTTP URL must target loopback, got %q", host) + } + return nil +} diff --git a/cmd/daemon/security_profile_test.go b/cmd/daemon/security_profile_test.go new file mode 100644 index 00000000..6d5dacf6 --- /dev/null +++ b/cmd/daemon/security_profile_test.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import "testing" + +func validEnterpriseDaemonOptions() daemonSecurityOptions { + return daemonSecurityOptions{ + RegistryAddr: "registry.example:443", + RegistryFingerprint: "abcdef", + RegistryTrust: "pinned", + IdentityPath: "/var/lib/pilot/identity.json", + EnterpriseControlPath: "control.json", + } +} + +func TestEnterpriseDaemonProfileLocksSafeSettings(t *testing.T) { + t.Parallel() + o := validEnterpriseDaemonOptions() + o.MOTDFeedURL = "https://example.invalid/motd.json" + if err := applyDaemonSecurityProfile(securityProfileEnterprise, &o); err != nil { + t.Fatal(err) + } + if !o.Encrypt || !o.RegistryTLS || !o.StrictDataPlaneTrust { + t.Fatalf("required settings not locked: %+v", o) + } + if !o.DisableSkillinject { + t.Fatal("unsigned skill injection was not disabled") + } + if o.MOTDFeedURL != "" { + t.Fatal("unsigned MOTD feed was not disabled") + } +} + +func TestEnterpriseDaemonProfileAllowsConfiguredSkillVerificationKey(t *testing.T) { + t.Parallel() + o := validEnterpriseDaemonOptions() + o.SkillinjectVerificationKeyFound = true + if err := applyDaemonSecurityProfile(securityProfileEnterprise, &o); err != nil { + t.Fatal(err) + } + if o.DisableSkillinject { + t.Fatal("signed skill injection was disabled") + } +} + +func TestEnterpriseDaemonProfileRejectsUnsafeConfiguration(t *testing.T) { + t.Parallel() + cases := map[string]func(*daemonSecurityOptions){ + "ephemeral identity": func(o *daemonSecurityOptions) { o.IdentityPath = "" }, + "auto trust": func(o *daemonSecurityOptions) { o.TrustAutoApprove = true }, + "missing pin": func(o *daemonSecurityOptions) { o.RegistryFingerprint = "" }, + "bad trust mode": func(o *daemonSecurityOptions) { o.RegistryTrust = "insecure" }, + "HTTP webhook": func(o *daemonSecurityOptions) { o.WebhookURL = "http://example.com/hook" }, + "missing control": func(o *daemonSecurityOptions) { o.EnterpriseControlPath = "" }, + } + for name, mutate := range cases { + mutate := mutate + t.Run(name, func(t *testing.T) { + t.Parallel() + o := validEnterpriseDaemonOptions() + mutate(&o) + if err := applyDaemonSecurityProfile(securityProfileEnterprise, &o); err == nil { + t.Fatalf("unsafe configuration accepted: %+v", o) + } + }) + } +} + +func TestEnterpriseDaemonProfileAllowsSystemTrustAndLoopbackWebhook(t *testing.T) { + t.Parallel() + o := validEnterpriseDaemonOptions() + o.RegistryTrust = "system" + o.RegistryFingerprint = "" + o.WebhookURL = "http://127.0.0.1:8080/hook" + if err := applyDaemonSecurityProfile(securityProfileEnterprise, &o); err != nil { + t.Fatal(err) + } +} + +func TestEnterpriseDaemonProfileAllowsBothGovernedTransportsToBeDisabled(t *testing.T) { + t.Parallel() + o := validEnterpriseDaemonOptions() + o.EnterpriseControlPath = "" + o.DisableDataExchange = true + o.DisableEventStream = true + if err := applyDaemonSecurityProfile(securityProfileEnterprise, &o); err != nil { + t.Fatal(err) + } +} + +func TestCompatibleDaemonProfilePreservesLegacySettings(t *testing.T) { + t.Parallel() + o := daemonSecurityOptions{MOTDFeedURL: "http://example.com/feed"} + if err := applyDaemonSecurityProfile(securityProfileCompatible, &o); err != nil { + t.Fatal(err) + } + if o.Encrypt || o.RegistryTLS || o.StrictDataPlaneTrust || o.DisableSkillinject || o.MOTDFeedURL == "" { + t.Fatalf("compatible profile changed settings: %+v", o) + } +} diff --git a/cmd/pilotctl/appstore.go b/cmd/pilotctl/appstore.go index ab4c251d..6010d305 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -1138,6 +1138,11 @@ func cmdAppStoreInstall(args []string) { "run `pilotctl appstore verify` for a side-by-side; this bundle is tampered or built from a different source than the manifest claims", "binary sha256 mismatch: manifest=%s actual=%s", m.Binary.SHA256, got) } + if err := validateHostExecutable(srcBin); err != nil { + fatalHint("platform_mismatch", + "this catalogue bundle is not executable on the current host; use a release that publishes a matching per-platform bundle", + "refusing incompatible app binary: %v", err) + } root := appStoreRoot() finalDir := filepath.Join(root, m.ID) diff --git a/cmd/pilotctl/enterprise.go b/cmd/pilotctl/enterprise.go new file mode 100644 index 00000000..074019c8 --- /dev/null +++ b/cmd/pilotctl/enterprise.go @@ -0,0 +1,610 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/pilot-protocol/common/authority" + "github.com/pilot-protocol/common/authorityhttp" + "github.com/pilot-protocol/common/decision" + "github.com/pilot-protocol/common/decisionhttp" + "github.com/pilot-protocol/common/decisionpolicy" +) + +const enterpriseHelpText = `Usage: pilotctl enterprise --endpoint --tenant [flags] + +Read the signed enterprise control state from an authority, or produce its +read-only dashboard URL. The policy subcommands submit already-signed +artifacts; this command never creates tenant authority signatures. + +Flags: + --endpoint authority base URL (HTTPS; HTTP is allowed only for loopback) + --tenant tenant identifier + --client-cert operator TLS client certificate PEM (API commands) + --client-key owner-only operator TLS private-key PEM (API commands) + --server-ca PEM CA bundle used to verify the authority (API commands) + --bearer-token-env environment variable holding an OIDC access token (API commands) + +Examples: + pilotctl enterprise status --endpoint https://authority.example --tenant acme \ + --client-cert operator.pem --client-key operator-key.pem --server-ca authority-ca.pem + pilotctl enterprise dashboard-url --endpoint https://authority.example --tenant acme + pilotctl enterprise trust publish --bundle root-signed-trust.json --endpoint https://authority.example --tenant acme + pilotctl enterprise policy status --endpoint https://authority.example --tenant acme --id rollout-42 + pilotctl enterprise mandate publish --bundle agent-mandates.json --endpoint https://authority.example --tenant acme + pilotctl enterprise receipt list --limit 50 --endpoint https://authority.example --tenant acme + pilotctl enterprise workflow cancel --id transaction-42 --reason "duplicate payment" --endpoint https://authority.example --tenant acme + +Policy lifecycle: + pilotctl enterprise policy simulate --publication --bundle (--intents | --inputs ) [flags] + pilotctl enterprise policy publish --publication --bundle [flags] + pilotctl enterprise policy activate --activation [flags] + pilotctl enterprise policy status --id [flags] + +Trust lifecycle: + pilotctl enterprise trust publish --bundle [flags] + +Mandate lifecycle: + pilotctl enterprise mandate publish --bundle [flags] + +Evidence: + pilotctl enterprise receipt list [--limit <1-1000>] [flags] + +Workflow operations: + pilotctl enterprise workflow list [--limit <1-1000>] [flags] + pilotctl enterprise workflow status --id [flags] + pilotctl enterprise workflow cancel --id --reason [flags] +` + +// cmdEnterprise is a read-only operator surface over the authority's signed +// management view. Policy publication and activation remain separate signed +// operations; this CLI deliberately does not turn a terminal into a bypass. +func cmdEnterprise(args []string) { + if len(args) == 0 { + fatalHint("invalid_argument", "available: pilotctl enterprise status | dashboard-url | trust | policy | mandate | receipt | workflow", "missing enterprise subcommand") + } + switch args[0] { + case "status": + cmdEnterpriseStatus(args[1:]) + case "dashboard-url": + cmdEnterpriseDashboardURL(args[1:]) + case "trust": + cmdEnterpriseTrust(args[1:]) + case "policy": + cmdEnterprisePolicy(args[1:]) + case "mandate": + cmdEnterpriseMandate(args[1:]) + case "receipt": + cmdEnterpriseReceipt(args[1:]) + case "workflow": + cmdEnterpriseWorkflow(args[1:]) + default: + fatalHint("invalid_argument", "available: status, dashboard-url, trust, policy, mandate, receipt, workflow", "unknown enterprise subcommand: %s", args[0]) + } +} + +type enterpriseFlags struct { + endpoint string + tenantID string + clientCert string + clientKey string + serverCA string + bearerTokenEnv string +} + +func enterpriseFlagSet(name string, result *enterpriseFlags) *flag.FlagSet { + flags := flag.NewFlagSet("enterprise "+name, flag.ContinueOnError) + flags.SetOutput(io.Discard) + flags.StringVar(&result.endpoint, "endpoint", "", "authority base URL") + flags.StringVar(&result.tenantID, "tenant", "", "tenant ID") + flags.StringVar(&result.clientCert, "client-cert", "", "operator mTLS client certificate PEM") + flags.StringVar(&result.clientKey, "client-key", "", "operator mTLS client private-key PEM") + flags.StringVar(&result.serverCA, "server-ca", "", "optional PEM CA bundle for authority TLS") + flags.StringVar(&result.bearerTokenEnv, "bearer-token-env", "", "environment variable holding an OIDC access token") + return flags +} + +func parseEnterpriseFlagSet(name string, flags *flag.FlagSet, result *enterpriseFlags, args []string) enterpriseFlags { + if err := flags.Parse(args); err != nil { + fatalCode("invalid_argument", "enterprise %s flags: %v", name, err) + } + if flags.NArg() != 0 { + fatalCode("invalid_argument", "enterprise %s accepts flags only", name) + } + if strings.TrimSpace(result.endpoint) == "" || strings.TrimSpace(result.tenantID) == "" { + fatalCode("invalid_argument", "enterprise %s requires --endpoint and --tenant", name) + } + if (result.clientCert == "") != (result.clientKey == "") { + fatalCode("invalid_argument", "enterprise %s requires --client-cert and --client-key together", name) + } + if result.bearerTokenEnv != "" && !enterpriseEnvironmentName(result.bearerTokenEnv) { + fatalCode("invalid_argument", "enterprise %s bearer-token-env must be an environment variable name", name) + } + return *result +} + +func parseEnterpriseFlags(name string, args []string) enterpriseFlags { + result := enterpriseFlags{} + return parseEnterpriseFlagSet(name, enterpriseFlagSet(name, &result), &result, args) +} + +func cmdEnterpriseStatus(args []string) { + options := parseEnterpriseFlags("status", args) + httpClient, err := enterpriseHTTPClient(options) + if err != nil { + fatalCode("invalid_argument", "enterprise status TLS configuration: %v", err) + } + client, err := authorityhttp.New(options.endpoint, httpClient) + if err != nil { + fatalCode("invalid_argument", "enterprise status endpoint: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + state, err := client.ManagementState(ctx, options.tenantID) + if err != nil { + fatalCode("unavailable", "enterprise status: %v", err) + } + output(map[string]interface{}{ + "tenant_id": state.Trust.TenantID, + "trust_revision": state.Trust.Revision, + "policy_revision": state.ActivePolicy.Revision, + "revocation_epoch": state.Trust.RevocationEpoch, + "rollouts": state.Rollouts, + "mandate_bundles": state.MandateBundles, + "recent_workflows": state.RecentWorkflows, + "recent_receipts": state.RecentReceipts, + "usage_export": state.UsageExport, + }) +} + +func cmdEnterpriseDashboardURL(args []string) { + options := parseEnterpriseFlags("dashboard-url", args) + if options.clientCert != "" || options.clientKey != "" || options.serverCA != "" || options.bearerTokenEnv != "" { + fatalCode("invalid_argument", "enterprise dashboard-url only prints a link; configure browser mTLS and trust directly") + } + if _, err := authorityhttp.New(options.endpoint, nil); err != nil { + fatalCode("invalid_argument", "enterprise dashboard endpoint: %v", err) + } + endpoint, err := url.Parse(options.endpoint) + if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" { + fatalCode("invalid_argument", "enterprise dashboard endpoint is invalid") + } + endpoint.Path = "/v1/manage/dashboard" + query := endpoint.Query() + query.Set("tenant_id", options.tenantID) + endpoint.RawQuery = query.Encode() + output(map[string]interface{}{"dashboard_url": endpoint.String(), "note": "The browser must present the configured operator mTLS certificate for remote access."}) +} + +func cmdEnterpriseTrust(args []string) { + if len(args) == 0 { + fatalHint("invalid_argument", "available: publish", "missing enterprise trust subcommand") + } + if args[0] != "publish" { + fatalHint("invalid_argument", "available: publish", "unknown enterprise trust subcommand: %s", args[0]) + } + base := enterpriseFlags{} + bundlePath := "" + flags := enterpriseFlagSet("trust publish", &base) + flags.StringVar(&bundlePath, "bundle", "", "root-signed trust bundle JSON") + options := parseEnterpriseFlagSet("trust publish", flags, &base, args[1:]) + if strings.TrimSpace(bundlePath) == "" { + fatalCode("invalid_argument", "enterprise trust publish requires --bundle") + } + bundle := readEnterpriseJSON[authority.TrustBundle](bundlePath, "trust bundle") + if bundle.TenantID != options.tenantID { + fatalCode("invalid_argument", "enterprise trust bundle tenant does not match --tenant") + } + client := enterpriseManagementClient(options, "trust publish") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + published, err := client.PublishTrust(ctx, bundle) + if err != nil { + fatalCode("unavailable", "enterprise trust publish: %v", err) + } + output(map[string]interface{}{"trust": published}) +} + +type enterprisePolicyFlags struct { + publicationPath string + bundlePath string + activationPath string + intentsPath string + inputsPath string + publicationID string +} + +func parseEnterprisePolicyFlags(name string, args []string) (enterpriseFlags, enterprisePolicyFlags) { + base := enterpriseFlags{} + policy := enterprisePolicyFlags{} + flags := enterpriseFlagSet("policy "+name, &base) + flags.StringVar(&policy.publicationPath, "publication", "", "signed policy publication JSON") + flags.StringVar(&policy.bundlePath, "bundle", "", "signed policy bundle JSON") + flags.StringVar(&policy.activationPath, "activation", "", "signed policy activation JSON") + flags.StringVar(&policy.intentsPath, "intents", "", "signed simulation intent array JSON") + flags.StringVar(&policy.inputsPath, "inputs", "", "signed simulation input array JSON (supports disclosure bindings)") + flags.StringVar(&policy.publicationID, "id", "", "policy publication ID") + return parseEnterpriseFlagSet("policy "+name, flags, &base, args), policy +} + +func cmdEnterprisePolicy(args []string) { + if len(args) == 0 { + fatalHint("invalid_argument", "available: publish, simulate, activate, status", "missing enterprise policy subcommand") + } + switch args[0] { + case "publish": + cmdEnterprisePolicyPublish(args[1:]) + case "simulate": + cmdEnterprisePolicySimulate(args[1:]) + case "activate": + cmdEnterprisePolicyActivate(args[1:]) + case "status": + cmdEnterprisePolicyStatus(args[1:]) + default: + fatalHint("invalid_argument", "available: publish, simulate, activate, status", "unknown enterprise policy subcommand: %s", args[0]) + } +} + +func cmdEnterprisePolicyPublish(args []string) { + options, policy := parseEnterprisePolicyFlags("publish", args) + if policy.publicationPath == "" || policy.bundlePath == "" { + fatalCode("invalid_argument", "enterprise policy publish requires --publication and --bundle") + } + publication := readEnterpriseJSON[authority.PolicyPublication](policy.publicationPath, "policy publication") + bundle := readEnterpriseJSON[authority.PolicyBundle](policy.bundlePath, "policy bundle") + if publication.TenantID != options.tenantID || bundle.TenantID != options.tenantID { + fatalCode("invalid_argument", "enterprise policy publish artifact tenant does not match --tenant") + } + client := enterpriseManagementClient(options, "publish") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + status, err := client.Publish(ctx, publication, bundle) + if err != nil { + fatalCode("unavailable", "enterprise policy publish: %v", err) + } + output(map[string]interface{}{"rollout": status}) +} + +func cmdEnterprisePolicySimulate(args []string) { + options, policy := parseEnterprisePolicyFlags("simulate", args) + if policy.publicationPath == "" || policy.bundlePath == "" || (policy.intentsPath == "" && policy.inputsPath == "") || (policy.intentsPath != "" && policy.inputsPath != "") { + fatalCode("invalid_argument", "enterprise policy simulate requires --publication, --bundle, and exactly one of --intents or --inputs") + } + publication := readEnterpriseJSON[authority.PolicyPublication](policy.publicationPath, "policy publication") + bundle := readEnterpriseJSON[authority.PolicyBundle](policy.bundlePath, "policy bundle") + request := authorityhttp.SimulationRequest{Publication: publication, Bundle: bundle} + if policy.inputsPath != "" { + request.Inputs = readEnterpriseJSON[[]decisionpolicy.SimulationInput](policy.inputsPath, "simulation inputs") + } else { + request.Intents = readEnterpriseJSON[[]decision.Intent](policy.intentsPath, "simulation intents") + } + if publication.TenantID != options.tenantID || bundle.TenantID != options.tenantID { + fatalCode("invalid_argument", "enterprise policy simulation artifact tenant does not match --tenant") + } + client := enterpriseManagementClient(options, "simulate") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + simulation, err := client.Simulate(ctx, request) + if err != nil { + fatalCode("unavailable", "enterprise policy simulate: %v", err) + } + output(map[string]interface{}{"simulation": simulation}) +} + +func cmdEnterprisePolicyActivate(args []string) { + options, policy := parseEnterprisePolicyFlags("activate", args) + if policy.activationPath == "" { + fatalCode("invalid_argument", "enterprise policy activate requires --activation") + } + activation := readEnterpriseJSON[authority.PolicyActivation](policy.activationPath, "policy activation") + if activation.TenantID != options.tenantID { + fatalCode("invalid_argument", "enterprise policy activation tenant does not match --tenant") + } + client := enterpriseManagementClient(options, "activate") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + status, err := client.Activate(ctx, activation) + if err != nil { + fatalCode("unavailable", "enterprise policy activate: %v", err) + } + output(map[string]interface{}{"rollout": status}) +} + +func cmdEnterprisePolicyStatus(args []string) { + options, policy := parseEnterprisePolicyFlags("status", args) + if strings.TrimSpace(policy.publicationID) == "" { + fatalCode("invalid_argument", "enterprise policy status requires --id") + } + client := enterpriseManagementClient(options, "status") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + status, err := client.Status(ctx, policy.publicationID) + if err != nil { + fatalCode("unavailable", "enterprise policy status: %v", err) + } + if status.Publication.TenantID != options.tenantID { + fatalCode("invalid_argument", "enterprise policy status tenant binding mismatch") + } + output(map[string]interface{}{"rollout": status}) +} + +func cmdEnterpriseMandate(args []string) { + if len(args) == 0 { + fatalHint("invalid_argument", "available: publish", "missing enterprise mandate subcommand") + } + if args[0] != "publish" { + fatalHint("invalid_argument", "available: publish", "unknown enterprise mandate subcommand: %s", args[0]) + } + base := enterpriseFlags{} + bundlePath := "" + flags := enterpriseFlagSet("mandate publish", &base) + flags.StringVar(&bundlePath, "bundle", "", "signed mandate bundle JSON") + options := parseEnterpriseFlagSet("mandate publish", flags, &base, args[1:]) + if strings.TrimSpace(bundlePath) == "" { + fatalCode("invalid_argument", "enterprise mandate publish requires --bundle") + } + bundle := readEnterpriseJSON[decision.MandateBundle](bundlePath, "mandate bundle") + if bundle.TenantID != options.tenantID { + fatalCode("invalid_argument", "enterprise mandate bundle tenant does not match --tenant") + } + client := enterpriseManagementClient(options, "mandate publish") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + published, err := client.PublishMandateBundle(ctx, bundle) + if err != nil { + fatalCode("unavailable", "enterprise mandate publish: %v", err) + } + output(map[string]interface{}{"mandate_bundle": published}) +} + +func cmdEnterpriseReceipt(args []string) { + if len(args) == 0 { + fatalHint("invalid_argument", "available: list", "missing enterprise receipt subcommand") + } + if args[0] != "list" { + fatalHint("invalid_argument", "available: list", "unknown enterprise receipt subcommand: %s", args[0]) + } + base := enterpriseFlags{} + limit := 100 + flags := enterpriseFlagSet("receipt list", &base) + flags.IntVar(&limit, "limit", 100, "maximum receipts to return (1-1000)") + options := parseEnterpriseFlagSet("receipt list", flags, &base, args[1:]) + if limit < 1 || limit > 1000 { + fatalCode("invalid_argument", "enterprise receipt list --limit must be 1-1000") + } + client := enterpriseManagementClient(options, "receipt list") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + receipts, err := client.Receipts(ctx, options.tenantID, limit) + if err != nil { + fatalCode("unavailable", "enterprise receipt list: %v", err) + } + output(map[string]interface{}{"receipts": receipts}) +} + +type enterpriseWorkflowFlags struct { + transactionID string + reason string + limit int +} + +func parseEnterpriseWorkflowFlags(name string, args []string) (enterpriseFlags, enterpriseWorkflowFlags) { + base := enterpriseFlags{} + workflow := enterpriseWorkflowFlags{} + flags := enterpriseFlagSet("workflow "+name, &base) + flags.StringVar(&workflow.transactionID, "id", "", "approval workflow transaction ID") + flags.StringVar(&workflow.reason, "reason", "", "non-empty cancellation reason") + flags.IntVar(&workflow.limit, "limit", 100, "maximum workflows to return (1-1000)") + return parseEnterpriseFlagSet("workflow "+name, flags, &base, args), workflow +} + +// cmdEnterpriseWorkflow is the operator-facing workflow surface. The +// authority mTLS boundary and the authority's signed cancellation artifact +// remain the actual control points; this CLI only carries their requests. +func cmdEnterpriseWorkflow(args []string) { + if len(args) == 0 { + fatalHint("invalid_argument", "available: list, status, cancel", "missing enterprise workflow subcommand") + } + switch args[0] { + case "list": + options, workflow := parseEnterpriseWorkflowFlags("list", args[1:]) + if workflow.limit < 1 || workflow.limit > 1000 { + fatalCode("invalid_argument", "enterprise workflow list --limit must be 1-1000") + } + client := enterpriseWorkflowClient(options, "workflow list") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + records, err := client.ManagementWorkflows(ctx, options.tenantID, workflow.limit) + if err != nil { + fatalCode("unavailable", "enterprise workflow list: %v", err) + } + output(map[string]interface{}{"workflows": records}) + case "status": + options, workflow := parseEnterpriseWorkflowFlags("status", args[1:]) + if strings.TrimSpace(workflow.transactionID) == "" { + fatalCode("invalid_argument", "enterprise workflow status requires --id") + } + client := enterpriseWorkflowClient(options, "workflow status") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + record, err := client.ManagementWorkflowStatus(ctx, workflow.transactionID) + if err != nil { + fatalCode("unavailable", "enterprise workflow status: %v", err) + } + if record.Transaction.TenantID != options.tenantID { + fatalCode("invalid_argument", "enterprise workflow status tenant binding mismatch") + } + output(map[string]interface{}{"workflow": record}) + case "cancel": + options, workflow := parseEnterpriseWorkflowFlags("cancel", args[1:]) + if strings.TrimSpace(workflow.transactionID) == "" || strings.TrimSpace(workflow.reason) == "" { + fatalCode("invalid_argument", "enterprise workflow cancel requires --id and --reason") + } + client := enterpriseWorkflowClient(options, "workflow cancel") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + record, err := client.CancelWorkflow(ctx, workflow.transactionID, workflow.reason) + if err != nil { + fatalCode("unavailable", "enterprise workflow cancel: %v", err) + } + if record.Transaction.TenantID != options.tenantID { + fatalCode("invalid_argument", "enterprise workflow cancellation tenant binding mismatch") + } + output(map[string]interface{}{"workflow": record}) + default: + fatalHint("invalid_argument", "available: list, status, cancel", "unknown enterprise workflow subcommand: %s", args[0]) + } +} + +func enterpriseManagementClient(options enterpriseFlags, operation string) *authorityhttp.Client { + httpClient, err := enterpriseHTTPClient(options) + if err != nil { + fatalCode("invalid_argument", "enterprise %s TLS configuration: %v", operation, err) + } + client, err := authorityhttp.New(options.endpoint, httpClient) + if err != nil { + fatalCode("invalid_argument", "enterprise %s endpoint: %v", operation, err) + } + return client +} + +func enterpriseWorkflowClient(options enterpriseFlags, operation string) *decisionhttp.Client { + httpClient, err := enterpriseHTTPClient(options) + if err != nil { + fatalCode("invalid_argument", "enterprise %s TLS configuration: %v", operation, err) + } + client, err := decisionhttp.New(options.endpoint, decisionhttp.WithHTTPClient(httpClient)) + if err != nil { + fatalCode("invalid_argument", "enterprise %s endpoint: %v", operation, err) + } + return client +} + +func readEnterpriseJSON[T any](path, label string) T { + if err := enterpriseCertificateFile(path); err != nil { + fatalCode("invalid_argument", "%s: %v", label, err) + } + file, err := os.Open(path) + if err != nil { + fatalCode("invalid_argument", "%s: %v", label, err) + } + defer file.Close() + decoder := json.NewDecoder(io.LimitReader(file, (2<<20)+1)) + decoder.DisallowUnknownFields() + var value T + if err := decoder.Decode(&value); err != nil { + fatalCode("invalid_argument", "%s: %v", label, err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + fatalCode("invalid_argument", "%s contains trailing data", label) + } + return value +} + +func enterpriseHTTPClient(options enterpriseFlags) (*http.Client, error) { + transport := http.DefaultTransport.(*http.Transport).Clone() + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS13} + if options.clientCert != "" { + if err := enterpriseCertificateFile(options.clientCert); err != nil { + return nil, fmt.Errorf("client certificate: %w", err) + } + if err := enterpriseSecretFile(options.clientKey); err != nil { + return nil, fmt.Errorf("client key: %w", err) + } + certificate, err := tls.LoadX509KeyPair(options.clientCert, options.clientKey) + if err != nil { + return nil, err + } + tlsConfig.Certificates = []tls.Certificate{certificate} + } + if options.serverCA != "" { + if err := enterpriseCertificateFile(options.serverCA); err != nil { + return nil, fmt.Errorf("server CA: %w", err) + } + contents, err := os.ReadFile(options.serverCA) + if err != nil { + return nil, fmt.Errorf("server CA: %w", err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(contents) { + return nil, fmt.Errorf("server CA contains no certificates") + } + tlsConfig.RootCAs = roots + } + transport.TLSClientConfig = tlsConfig + var roundTripper http.RoundTripper = transport + if options.bearerTokenEnv != "" { + token := strings.TrimSpace(os.Getenv(options.bearerTokenEnv)) + if token == "" || len(token) > 16<<10 || strings.ContainsAny(token, " \t\r\n") { + return nil, fmt.Errorf("bearer token environment variable %q is empty or invalid", options.bearerTokenEnv) + } + roundTripper = enterpriseBearerTransport{next: transport, token: token} + } + return &http.Client{Transport: roundTripper, Timeout: 15 * time.Second}, nil +} + +type enterpriseBearerTransport struct { + next http.RoundTripper + token string +} + +func (transport enterpriseBearerTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if request == nil { + return nil, fmt.Errorf("enterprise bearer transport: request is nil") + } + clone := request.Clone(request.Context()) + clone.Header = request.Header.Clone() + clone.Header.Set("Authorization", "Bearer "+transport.token) + return transport.next.RoundTrip(clone) +} + +func enterpriseEnvironmentName(value string) bool { + if value == "" { + return false + } + for index, character := range value { + if !(character == '_' || character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z' || + (index > 0 && character >= '0' && character <= '9')) { + return false + } + } + return true +} + +func enterpriseCertificateFile(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("must be a regular file") + } + return nil +} + +func enterpriseSecretFile(path string) error { + if err := enterpriseCertificateFile(path); err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("must be owner-only (mode 0600 or stricter)") + } + return nil +} diff --git a/cmd/pilotctl/enterprise_test.go b/cmd/pilotctl/enterprise_test.go new file mode 100644 index 00000000..6bb4521b --- /dev/null +++ b/cmd/pilotctl/enterprise_test.go @@ -0,0 +1,467 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/pilot-protocol/common/authority" + "github.com/pilot-protocol/common/authorityhttp" + "github.com/pilot-protocol/common/decision" + "github.com/pilot-protocol/common/decisionhttp" +) + +func TestCLIEnterpriseDashboardURL(t *testing.T) { + t.Parallel() + stdout, stderr, code := runCLI(t, []string{ + "--json", "enterprise", "dashboard-url", + "--endpoint", "https://authority.example", "--tenant", "acme", + }, nil) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr) + } + var envelope struct { + Status string `json:"status"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout) + } + if envelope.Status != "ok" { + t.Fatalf("status=%q", envelope.Status) + } + if got, want := envelope.Data["dashboard_url"], "https://authority.example/v1/manage/dashboard?tenant_id=acme"; got != want { + t.Errorf("dashboard_url=%v, want %q", got, want) + } +} + +func TestCLIEnterpriseHelp(t *testing.T) { + t.Parallel() + stdout, stderr, code := runCLI(t, []string{"enterprise", "--help"}, nil) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr) + } + if !strings.Contains(stdout+stderr, "dashboard-url") { + t.Errorf("enterprise help missing dashboard command: %s%s", stdout, stderr) + } +} + +func TestCLIEnterpriseStatus(t *testing.T) { + state := enterpriseStateForTest(t) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1/manage/control-state" || request.URL.Query().Get("tenant_id") != "acme" { + t.Errorf("request=%s", request.URL.String()) + writer.WriteHeader(http.StatusNotFound) + return + } + writer.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(writer).Encode(state); err != nil { + t.Errorf("encode state: %v", err) + } + })) + defer server.Close() + + stdout, stderr, code := runCLI(t, []string{ + "--json", "enterprise", "status", "--endpoint", server.URL, "--tenant", "acme", + }, nil) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr) + } + var envelope struct { + Status string `json:"status"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout) + } + if envelope.Status != "ok" || envelope.Data["tenant_id"] != "acme" { + t.Fatalf("response=%s", stdout) + } + if got := envelope.Data["trust_revision"]; got != float64(7) { + t.Errorf("trust_revision=%v, want 7", got) + } + if got := envelope.Data["policy_revision"]; got != float64(9) { + t.Errorf("policy_revision=%v, want 9", got) + } + if _, found := envelope.Data["recent_workflows"]; !found { + t.Errorf("status response omits recent workflows: %s", stdout) + } + if _, found := envelope.Data["usage_export"]; !found { + t.Errorf("status response omits usage export: %s", stdout) + } +} + +func TestCLIEnterpriseStatusOIDCBearerEnvironment(t *testing.T) { + state := enterpriseStateForTest(t) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer keycloak-access-token" { + t.Errorf("authorization=%q", request.Header.Get("Authorization")) + writer.WriteHeader(http.StatusForbidden) + return + } + if request.URL.Path != "/v1/manage/control-state" || request.URL.Query().Get("tenant_id") != "acme" { + t.Errorf("request=%s", request.URL.String()) + writer.WriteHeader(http.StatusNotFound) + return + } + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(state) + })) + defer server.Close() + stdout, stderr, code := runCLI(t, []string{ + "--json", "enterprise", "status", "--endpoint", server.URL, "--tenant", "acme", "--bearer-token-env", "PILOT_OIDC_TOKEN", + }, map[string]string{"PILOT_OIDC_TOKEN": "keycloak-access-token"}) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr) + } + if !strings.Contains(stdout, `"tenant_id":"acme"`) { + t.Fatalf("status output=%s", stdout) + } +} + +func TestCLIEnterprisePolicyStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1/manage/policy-status" || request.URL.Query().Get("id") != "rollout-42" { + t.Errorf("request=%s", request.URL.String()) + writer.WriteHeader(http.StatusNotFound) + return + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"publication":{"id":"rollout-42","tenant_id":"acme"},"staged":1,"pending":[]}`)) + })) + defer server.Close() + + stdout, stderr, code := runCLI(t, []string{ + "--json", "enterprise", "policy", "status", "--endpoint", server.URL, "--tenant", "acme", "--id", "rollout-42", + }, nil) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr) + } + var envelope struct { + Status string `json:"status"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout) + } + rollout, _ := envelope.Data["rollout"].(map[string]interface{}) + publication, _ := rollout["publication"].(map[string]interface{}) + if envelope.Status != "ok" || publication["id"] != "rollout-42" { + t.Fatalf("response=%s", stdout) + } +} + +func TestCLIEnterpriseMandatePublish(t *testing.T) { + now := time.Now().UTC() + bundle := decision.MandateBundle{ + Version: decision.SchemaVersion, TenantID: "acme", SubjectAgentID: "agent-1", Revision: 1, RevocationEpoch: 1, + IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), KeyID: "issuer-key", Signature: "signed", + } + path := filepath.Join(t.TempDir(), "mandate-bundle.json") + body, err := json.Marshal(bundle) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1/manage/mandates" || request.Method != http.MethodPost { + t.Errorf("request=%s %s", request.Method, request.URL.String()) + writer.WriteHeader(http.StatusNotFound) + return + } + hash, _ := bundle.Hash() + if request.Header.Get("Idempotency-Key") != hash { + t.Errorf("idempotency key=%q", request.Header.Get("Idempotency-Key")) + writer.WriteHeader(http.StatusBadRequest) + return + } + _ = json.NewEncoder(writer).Encode(bundle) + })) + defer server.Close() + stdout, stderr, code := runCLI(t, []string{ + "--json", "enterprise", "mandate", "publish", "--endpoint", server.URL, "--tenant", "acme", "--bundle", path, + }, nil) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr) + } + if !strings.Contains(stdout, `"mandate_bundle"`) || !strings.Contains(stdout, `"subject_agent_id":"agent-1"`) { + t.Fatalf("publish output=%s", stdout) + } +} + +func TestCLIEnterpriseTrustPublish(t *testing.T) { + bundle := enterpriseStateForTest(t).Trust + path := filepath.Join(t.TempDir(), "trust-bundle.json") + body, err := json.Marshal(bundle) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + hash, err := bundle.Hash() + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1/manage/trust" || request.Method != http.MethodPost || request.Header.Get("Idempotency-Key") != hash { + t.Errorf("request=%s %s idempotency=%q", request.Method, request.URL.String(), request.Header.Get("Idempotency-Key")) + writer.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(writer).Encode(authorityhttp.TrustPublicationResult{TenantID: bundle.TenantID, Revision: bundle.Revision, BundleHash: hash}) + })) + defer server.Close() + stdout, stderr, code := runCLI(t, []string{ + "--json", "enterprise", "trust", "publish", "--endpoint", server.URL, "--tenant", "acme", "--bundle", path, + }, nil) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr) + } + if !strings.Contains(stdout, `"trust"`) || !strings.Contains(stdout, `"revision":7`) || !strings.Contains(stdout, hash) { + t.Fatalf("trust publish output=%s", stdout) + } +} + +func TestCLIEnterpriseReceiptList(t *testing.T) { + receiptID, err := decision.ReceiptID("decision-1", "wallet") + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1/manage/receipts" || request.URL.Query().Get("tenant_id") != "acme" || request.URL.Query().Get("limit") != "5" { + t.Errorf("request=%s", request.URL.String()) + writer.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(writer).Encode(map[string]any{"receipts": []decision.Receipt{{ + Version: decision.SchemaVersion, ID: receiptID, DecisionID: "decision-1", DecisionHash: strings.Repeat("a", 64), IntentHash: strings.Repeat("b", 64), + TenantID: "acme", AgentID: "agent-1", Outcome: decision.Allow, Result: decision.Enforced, EnforcementPoint: "wallet", ObservedAt: time.Now().Unix(), KeyID: "receipt-key", Signature: "signed", + }}}) + })) + defer server.Close() + stdout, stderr, code := runCLI(t, []string{ + "--json", "enterprise", "receipt", "list", "--endpoint", server.URL, "--tenant", "acme", "--limit", "5", + }, nil) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr) + } + if !strings.Contains(stdout, `"receipts"`) || !strings.Contains(stdout, receiptID) { + t.Fatalf("receipt output=%s", stdout) + } +} + +func TestCLIEnterpriseWorkflowStatusAndCancellation(t *testing.T) { + record, authorityPrivate := enterpriseWorkflowRecordForTest(t) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v1/manage/workflows": + if request.Method != http.MethodGet || request.URL.Query().Get("tenant_id") != "acme" || request.URL.Query().Get("limit") != "5" { + t.Errorf("list request=%s %s", request.Method, request.URL.String()) + writer.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(writer).Encode(decisionhttp.WorkflowListResponse{Workflows: []decisionhttp.WorkflowRecord{record}}) + case "/v1/manage/workflow-status": + if request.Method != http.MethodGet || request.URL.Query().Get("transaction_id") != record.Transaction.ID { + t.Errorf("status request=%s %s", request.Method, request.URL.String()) + writer.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(writer).Encode(record) + case "/v1/manage/workflow-cancel": + var cancellationRequest decisionhttp.WorkflowCancelRequest + if err := json.NewDecoder(request.Body).Decode(&cancellationRequest); err != nil { + t.Errorf("decode cancellation: %v", err) + writer.WriteHeader(http.StatusBadRequest) + return + } + sum := sha256.Sum256([]byte(cancellationRequest.TransactionID + "\x00" + cancellationRequest.Reason)) + if request.Method != http.MethodPost || cancellationRequest.TransactionID != record.Transaction.ID || request.Header.Get("Idempotency-Key") != hex.EncodeToString(sum[:]) { + t.Errorf("cancel request=%s body=%+v idempotency=%q", request.Method, cancellationRequest, request.Header.Get("Idempotency-Key")) + writer.WriteHeader(http.StatusBadRequest) + return + } + cancellation, err := decision.NewApprovalCancellation(record.Transaction, cancellationRequest.Reason, time.Now(), record.Transaction.ProviderID, record.Transaction.KeyID) + if err != nil { + t.Errorf("new cancellation: %v", err) + writer.WriteHeader(http.StatusInternalServerError) + return + } + if err := cancellation.Sign(authorityPrivate); err != nil { + t.Errorf("sign cancellation: %v", err) + writer.WriteHeader(http.StatusInternalServerError) + return + } + cancelled := record + cancelled.Cancellation = &cancellation + _ = json.NewEncoder(writer).Encode(cancelled) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + listOutput, stderr, code := runCLI(t, []string{ + "--json", "enterprise", "workflow", "list", "--endpoint", server.URL, "--tenant", "acme", "--limit", "5", + }, nil) + if code != 0 { + t.Fatalf("list exit=%d stderr=%s", code, stderr) + } + if !strings.Contains(listOutput, `"workflows"`) || !strings.Contains(listOutput, record.Transaction.ID) { + t.Fatalf("list output=%s", listOutput) + } + statusOutput, stderr, code := runCLI(t, []string{ + "--json", "enterprise", "workflow", "status", "--endpoint", server.URL, "--tenant", "acme", "--id", record.Transaction.ID, + }, nil) + if code != 0 { + t.Fatalf("status exit=%d stderr=%s", code, stderr) + } + if !strings.Contains(statusOutput, `"workflow"`) || !strings.Contains(statusOutput, record.Transaction.ID) { + t.Fatalf("status output=%s", statusOutput) + } + cancelOutput, stderr, code := runCLI(t, []string{ + "--json", "enterprise", "workflow", "cancel", "--endpoint", server.URL, "--tenant", "acme", "--id", record.Transaction.ID, "--reason", "operator stop", + }, nil) + if code != 0 { + t.Fatalf("cancel exit=%d stderr=%s", code, stderr) + } + if !strings.Contains(cancelOutput, `"cancellation"`) || !strings.Contains(cancelOutput, "operator stop") { + t.Fatalf("cancel output=%s", cancelOutput) + } +} + +func enterpriseWorkflowRecordForTest(t *testing.T) (decisionhttp.WorkflowRecord, ed25519.PrivateKey) { + t.Helper() + _, agentPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + _, authorityPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + intent := decision.Intent{ + Version: decision.SchemaVersion, ID: "workflow-intent", TenantID: "acme", AgentID: "agent-1", Action: "message.send", Resource: "agent:finance", + PayloadHash: decision.HashPayload([]byte("invoice")), Risk: decision.RiskHigh, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), Nonce: strings.Repeat("1", 32), KeyID: "agent-key-1", + } + if err := intent.Sign(agentPrivate); err != nil { + t.Fatal(err) + } + intentHash, err := intent.Hash() + if err != nil { + t.Fatal(err) + } + initial := decision.Decision{ + Version: decision.SchemaVersion, ID: "workflow-initial", IntentHash: intentHash, TenantID: intent.TenantID, AgentID: intent.AgentID, + Outcome: decision.ApprovalRequired, PolicyRevision: 1, RevocationEpoch: 1, ProviderID: "authority-1", IssuedAt: now.Unix(), ExpiresAt: intent.ExpiresAt, KeyID: "authority-key-1", + } + if err := initial.Sign(authorityPrivate); err != nil { + t.Fatal(err) + } + transaction, err := decision.NewApprovalTransaction(intent, initial, decision.Allow, nil, []string{"approval-key-1"}, 1, now, now.Add(time.Hour), "authority-1", "authority-key-1") + if err != nil { + t.Fatal(err) + } + if err := transaction.Sign(authorityPrivate); err != nil { + t.Fatal(err) + } + return decisionhttp.WorkflowRecord{Transaction: transaction}, authorityPrivate +} + +func TestCLIEnterpriseStatusRequiresCompleteMTLSIdentity(t *testing.T) { + t.Parallel() + _, stderr, code := runCLI(t, []string{ + "enterprise", "status", "--endpoint", "http://127.0.0.1:1", "--tenant", "acme", + "--client-cert", "operator.pem", + }, nil) + if code == 0 { + t.Fatal("expected non-zero exit") + } + if !strings.Contains(stderr, "--client-cert and --client-key together") { + t.Errorf("missing mTLS identity explanation: %s", stderr) + } +} + +func TestCLIEnterpriseDashboardURLRejectsRemotePlaintext(t *testing.T) { + t.Parallel() + _, stderr, code := runCLI(t, []string{ + "enterprise", "dashboard-url", "--endpoint", "http://authority.example", "--tenant", "acme", + }, nil) + if code == 0 { + t.Fatal("expected non-zero exit") + } + if !strings.Contains(stderr, "must use HTTPS") { + t.Errorf("missing HTTPS explanation: %s", stderr) + } +} + +func TestEnterpriseSecretFileRejectsWorldReadableKey(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "operator-key.pem") + if err := os.WriteFile(path, []byte("not a key"), 0o644); err != nil { + t.Fatal(err) + } + if err := enterpriseSecretFile(path); err == nil || !strings.Contains(err.Error(), "owner-only") { + t.Fatalf("err=%v, want owner-only file rejection", err) + } +} + +func TestEnterpriseCertificateFileRejectsSymlink(t *testing.T) { + t.Parallel() + dir := t.TempDir() + target := filepath.Join(dir, "operator.pem") + if err := os.WriteFile(target, []byte("certificate"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "operator-link.pem") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if err := enterpriseCertificateFile(link); err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("err=%v, want symlink rejection", err) + } +} + +func enterpriseStateForTest(t *testing.T) authorityhttp.ManagementState { + t.Helper() + now := time.Now().UTC() + issuerPublic, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + _, rootPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + trust := authority.TrustBundle{ + Version: authority.SchemaVersion, TenantID: "acme", Revision: 7, PolicyRevision: 9, RevocationEpoch: 2, + IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), RootKeyID: "root-key", + Keys: []authority.AuthorityKey{{ + KeyID: "issuer-key", PublicKey: base64.StdEncoding.EncodeToString(issuerPublic), + Usages: []authority.KeyUsage{authority.UsagePolicy}, NotBefore: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), + }}, + } + if err := trust.Sign(rootPrivate); err != nil { + t.Fatal(err) + } + policy := authority.NewPolicyBundle( + "acme", 9, 2, now, now, now.Add(time.Minute), "policy-engine", "1", "application/json", "issuer-key", []byte(`{"default":"deny"}`), + ) + return authorityhttp.ManagementState{Trust: trust, ActivePolicy: policy} +} diff --git a/cmd/pilotctl/executable_platform.go b/cmd/pilotctl/executable_platform.go new file mode 100644 index 00000000..ac642e46 --- /dev/null +++ b/cmd/pilotctl/executable_platform.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "encoding/binary" + "fmt" + "os" + "runtime" +) + +// validateHostExecutable rejects a known native executable format built for a +// different host before it reaches the app supervisor. This is the fail-safe +// for legacy catalogue entries that predate per-platform bundle selection. +// Scripts and unrecognised adapter formats remain valid and are left to the OS. +func validateHostExecutable(path string) error { + return validateExecutablePlatform(path, runtime.GOOS, runtime.GOARCH) +} + +func validateExecutablePlatform(path, wantOS, wantArch string) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("open executable: %w", err) + } + defer f.Close() + + header := make([]byte, 32) + n, err := f.Read(header) + if err != nil && n == 0 { + return fmt.Errorf("read executable header: %w", err) + } + header = header[:n] + if len(header) >= 2 && string(header[:2]) == "#!" { + return nil + } + if len(header) >= 20 && string(header[:4]) == "\x7fELF" { + if wantOS != "linux" { + return fmt.Errorf("binary format is ELF/Linux, host is %s/%s", wantOS, wantArch) + } + var order binary.ByteOrder + switch header[5] { + case 1: + order = binary.LittleEndian + case 2: + order = binary.BigEndian + default: + return fmt.Errorf("ELF header has invalid byte order %d", header[5]) + } + machine := order.Uint16(header[18:20]) + if !elfMachineMatches(machine, wantArch) { + return fmt.Errorf("ELF machine %d does not match host architecture %s", machine, wantArch) + } + return nil + } + + if len(header) >= 8 { + magic := binary.BigEndian.Uint32(header[:4]) + var order binary.ByteOrder + switch magic { + case 0xfeedface, 0xfeedfacf: + order = binary.BigEndian + case 0xcefaedfe, 0xcffaedfe: + order = binary.LittleEndian + } + if order != nil { + if wantOS != "darwin" { + return fmt.Errorf("binary format is Mach-O/macOS, host is %s/%s", wantOS, wantArch) + } + cpu := order.Uint32(header[4:8]) + if !machCPUMatches(cpu, wantArch) { + return fmt.Errorf("Mach-O CPU %#x does not match host architecture %s", cpu, wantArch) + } + } + } + return nil +} + +func elfMachineMatches(machine uint16, arch string) bool { + switch arch { + case "amd64": + return machine == 62 // EM_X86_64 + case "arm64": + return machine == 183 // EM_AARCH64 + default: + return false + } +} + +func machCPUMatches(cpu uint32, arch string) bool { + switch arch { + case "amd64": + return cpu == 0x01000007 + case "arm64": + return cpu == 0x0100000c + default: + return false + } +} diff --git a/cmd/pilotctl/executable_platform_test.go b/cmd/pilotctl/executable_platform_test.go new file mode 100644 index 00000000..08836c1c --- /dev/null +++ b/cmd/pilotctl/executable_platform_test.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "encoding/binary" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeExecutableHeader(t *testing.T, header []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "app") + if err := os.WriteFile(path, header, 0o755); err != nil { + t.Fatal(err) + } + return path +} + +func TestValidateExecutablePlatformRejectsWrongOSAndArchitecture(t *testing.T) { + t.Parallel() + elf := make([]byte, 32) + copy(elf, "\x7fELF") + elf[5] = 1 + binary.LittleEndian.PutUint16(elf[18:20], 62) + elfPath := writeExecutableHeader(t, elf) + if err := validateExecutablePlatform(elfPath, "linux", "amd64"); err != nil { + t.Fatalf("matching ELF rejected: %v", err) + } + if err := validateExecutablePlatform(elfPath, "darwin", "amd64"); err == nil || !strings.Contains(err.Error(), "ELF/Linux") { + t.Fatalf("ELF on Darwin error = %v", err) + } + if err := validateExecutablePlatform(elfPath, "linux", "arm64"); err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("amd64 ELF on arm64 error = %v", err) + } + + mach := make([]byte, 32) + binary.LittleEndian.PutUint32(mach[:4], 0xfeedfacf) + binary.LittleEndian.PutUint32(mach[4:8], 0x0100000c) + machPath := writeExecutableHeader(t, mach) + if err := validateExecutablePlatform(machPath, "darwin", "arm64"); err != nil { + t.Fatalf("matching Mach-O rejected: %v", err) + } + if err := validateExecutablePlatform(machPath, "linux", "arm64"); err == nil || !strings.Contains(err.Error(), "Mach-O/macOS") { + t.Fatalf("Mach-O on Linux error = %v", err) + } +} + +func TestValidateExecutablePlatformAllowsScriptsAndUnknownAdapters(t *testing.T) { + t.Parallel() + for _, content := range [][]byte{[]byte("#!/bin/sh\nexit 0\n"), []byte("adapter-v1\n")} { + if err := validateExecutablePlatform(writeExecutableHeader(t, content), "linux", "amd64"); err != nil { + t.Fatalf("non-native executable rejected: %v", err) + } + } +} diff --git a/cmd/pilotctl/governed_transport.go b/cmd/pilotctl/governed_transport.go new file mode 100644 index 00000000..a1be41ae --- /dev/null +++ b/cmd/pilotctl/governed_transport.go @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "strings" + "sync" + "time" + + "github.com/pilot-protocol/common/actionhook" + "github.com/pilot-protocol/common/decision" + "github.com/pilot-protocol/dataexchange" + "github.com/pilot-protocol/pilotprotocol/internal/enterprisecontrol" +) + +const maxInlineHostedFederationBytes int64 = 16 << 20 + +// outboundDecisionRequester is intentionally narrow so the transport command +// code never gains access to a configured private key. The enterprise-control +// runtime owns signing, request construction, and local verification. +type outboundDecisionRequester interface { + HasOutboundDecisions() bool + AuthorizeOutbound(context.Context, string, string, string) (decision.Intent, decision.Decision, error) +} + +var loadOutboundDecisionRequester = func(path string) (outboundDecisionRequester, error) { + return enterprisecontrol.Load(path) +} + +type governedOutboundSender struct { + requester outboundDecisionRequester + resource string + hook actionhook.Hook + artifacts interface { + ActionArtifacts(actionhook.Preflight) (decision.Intent, decision.Decision, bool) + } + contentBuilder interface { + NewOutboundFederatedContent(string, string, []byte) (decision.FederatedContent, error) + NewOutboundFederatedResponseContent(string, string, []byte) (decision.FederatedContent, error) + } + mu sync.Mutex + attempts map[string]governedActionAttempt + disclosures map[string]decision.DisclosureBinding +} + +type governedActionAttempt struct { + envelope actionhook.Envelope + preflight actionhook.Preflight +} + +type outboundActionHookProvider interface { + ActionHook() actionhook.Hook + ActionArtifacts(actionhook.Preflight) (decision.Intent, decision.Decision, bool) +} + +type outboundFederationContentProvider interface { + NewOutboundFederatedContent(string, string, []byte) (decision.FederatedContent, error) + NewOutboundFederatedResponseContent(string, string, []byte) (decision.FederatedContent, error) +} + +// governedOutboundFromFlags is opt-in. Without the attachment argument (or +// environment equivalent), ordinary open-agent transport behavior is +// unchanged. The resource is explicit because it is owned by the receiver; +// inferring one from a sender address would create an authorization bypass. +func governedOutboundFromFlags(flags map[string]string) (*governedOutboundSender, error) { + path := strings.TrimSpace(flagString(flags, "enterprise-control", "")) + if path == "" { + path = strings.TrimSpace(os.Getenv("PILOT_ENTERPRISE_CONTROL")) + } + if path == "" { + return nil, nil + } + resource := strings.TrimSpace(flagString(flags, "governed-resource", "")) + if resource == "" { + resource = strings.TrimSpace(os.Getenv("PILOT_GOVERNED_RESOURCE")) + } + if resource == "" { + return nil, fmt.Errorf("--governed-resource is required with --enterprise-control") + } + requester, err := loadOutboundDecisionRequester(path) + if err != nil { + return nil, fmt.Errorf("load enterprise control: %w", err) + } + if !requester.HasOutboundDecisions() { + return nil, fmt.Errorf("enterprise control does not configure outbound_decisions") + } + sender := &governedOutboundSender{requester: requester, resource: resource, attempts: make(map[string]governedActionAttempt), disclosures: make(map[string]decision.DisclosureBinding)} + if provider, ok := requester.(outboundActionHookProvider); ok { + sender.hook = provider.ActionHook() + sender.artifacts = provider + } + if provider, ok := requester.(outboundFederationContentProvider); ok { + sender.contentBuilder = provider + } + return sender, nil +} + +func (sender *governedOutboundSender) authorizeFrame(ctx context.Context, frame *dataexchange.Frame) (decision.Intent, decision.Decision, error) { + if sender == nil || sender.requester == nil || frame == nil { + return decision.Intent{}, decision.Decision{}, fmt.Errorf("governed outbound sender is not initialized") + } + action := dataexchange.GovernedAction(frame.Type) + if action == "" { + return decision.Intent{}, decision.Decision{}, fmt.Errorf("frame type %s cannot be sent as governed data", dataexchange.TypeName(frame.Type)) + } + payloadHash := dataexchange.GovernedPayloadHash(frame.Type, frame.Filename, frame.Payload) + var content *decision.FederatedContent + if sender.hook != nil && sender.contentBuilder != nil { + if int64(len(frame.Payload)) > maxInlineHostedFederationBytes { + return decision.Intent{}, decision.Decision{}, fmt.Errorf("hosted federation inline content exceeds %d bytes", maxInlineHostedFederationBytes) + } + created, contentErr := sender.contentBuilder.NewOutboundFederatedContent(frameContentType(frame.Type), frame.Filename, frame.Payload) + if contentErr != nil { + return decision.Intent{}, decision.Decision{}, contentErr + } + content = &created + payloadHash, _ = created.Disclosure.Hash() + } + intent, result, err := sender.authorizeAction(ctx, action, payloadHash, "frame:"+action+":"+payloadHash, content) + if err != nil { + return decision.Intent{}, decision.Decision{}, err + } + if err := outboundDecisionPermits(result); err != nil { + return intent, result, err + } + if content != nil { + sender.mu.Lock() + if sender.disclosures == nil { + sender.disclosures = make(map[string]decision.DisclosureBinding) + } + sender.disclosures[intent.ID] = content.Disclosure + sender.mu.Unlock() + } + return intent, result, nil +} + +func (sender *governedOutboundSender) authorizeStream(ctx context.Context, initPayload []byte) (decision.Intent, decision.Decision, error) { + if sender == nil || sender.requester == nil { + return decision.Intent{}, decision.Decision{}, fmt.Errorf("governed outbound sender is not initialized") + } + payloadHash := dataexchange.GovernedStreamPayloadHash(initPayload) + intent, result, err := sender.authorizeAction(ctx, "file.share", payloadHash, "stream:file.share:"+payloadHash, nil) + if err != nil { + return decision.Intent{}, decision.Decision{}, err + } + if err := outboundDecisionPermits(result); err != nil { + return intent, result, err + } + return intent, result, nil +} + +func (sender *governedOutboundSender) authorizeFederatedStream(ctx context.Context, initPayload, body []byte) (decision.Intent, decision.Decision, decision.DisclosureBinding, error) { + if sender == nil || sender.hook == nil || sender.contentBuilder == nil { + intent, result, err := sender.authorizeStream(ctx, initPayload) + return intent, result, decision.DisclosureBinding{}, err + } + transferID, declaredBytes, contentHash, filename, err := dataexchange.GovernedStreamDisclosureMetadata(initPayload) + if err != nil { + return decision.Intent{}, decision.Decision{}, decision.DisclosureBinding{}, err + } + if declaredBytes != uint64(len(body)) || contentHash != decision.HashPayload(body) { + return decision.Intent{}, decision.Decision{}, decision.DisclosureBinding{}, fmt.Errorf("governed stream body does not match final INIT") + } + content, err := sender.contentBuilder.NewOutboundFederatedContent("application/octet-stream", filename, body) + if err != nil { + return decision.Intent{}, decision.Decision{}, decision.DisclosureBinding{}, err + } + content.Disclosure.TransferID = transferID + content, err = decision.NewFederatedContent(content.Disclosure, body) + if err != nil { + return decision.Intent{}, decision.Decision{}, decision.DisclosureBinding{}, err + } + payloadHash, _ := content.Disclosure.Hash() + intent, result, err := sender.authorizeAction(ctx, "file.share", payloadHash, "stream:file.share:"+payloadHash, &content) + if err != nil { + return decision.Intent{}, decision.Decision{}, decision.DisclosureBinding{}, err + } + if err := outboundDecisionPermits(result); err != nil { + return intent, result, decision.DisclosureBinding{}, err + } + sender.mu.Lock() + if sender.disclosures == nil { + sender.disclosures = make(map[string]decision.DisclosureBinding) + } + sender.disclosures[intent.ID] = content.Disclosure + sender.mu.Unlock() + return intent, result, content.Disclosure, nil +} + +func (sender *governedOutboundSender) authorizeAction(ctx context.Context, action, payloadHash, resumeToken string, content *decision.FederatedContent) (decision.Intent, decision.Decision, error) { + if sender.hook == nil { + return sender.requester.AuthorizeOutbound(ctx, action, sender.resource, payloadHash) + } + var envelope actionhook.Envelope + var err error + if content != nil { + envelope, err = actionhook.NewFederatedEnvelope(action, sender.resource, "pilot.dataexchange", *content, map[string]string{"transport": "dataexchange"}, time.Now()) + } else { + envelope, err = actionhook.NewEnvelope(action, sender.resource, payloadHash, "pilot.dataexchange", map[string]string{"transport": "dataexchange"}, time.Now()) + } + if err != nil { + return decision.Intent{}, decision.Decision{}, err + } + envelope.ResumeToken = resumeToken + if err := envelope.Validate(); err != nil { + return decision.Intent{}, decision.Decision{}, err + } + preflight, err := sender.hook.BeforeAction(ctx, envelope) + if err != nil { + return decision.Intent{}, decision.Decision{}, err + } + intent, result, hasArtifacts := sender.artifacts.ActionArtifacts(preflight) + if executeErr := preflight.RequireUnconstrained(); executeErr != nil { + status := actionhook.StatusFailed + var blocked *actionhook.BlockedError + if errors.As(executeErr, &blocked) { + switch blocked.Outcome { + case decision.Deny: + status = actionhook.StatusDenied + case decision.ApprovalRequired: + status = actionhook.StatusApprovalPending + } + } + _ = sender.hook.AfterAction(ctx, envelope, preflight, actionhook.ObservedResult{Status: status, ObservedAt: time.Now().Unix(), ErrorCode: "preflight_blocked"}) + return intent, result, executeErr + } + if !hasArtifacts { + intent, result, err = sender.requester.AuthorizeOutbound(ctx, action, sender.resource, payloadHash) + if err != nil { + _ = sender.hook.AfterAction(ctx, envelope, preflight, actionhook.ObservedResult{Status: actionhook.StatusFailed, ObservedAt: time.Now().Unix(), ErrorCode: "wire_authorization_failed"}) + return decision.Intent{}, decision.Decision{}, err + } + } + if err := outboundDecisionPermits(result); err != nil { + _ = sender.hook.AfterAction(ctx, envelope, preflight, actionhook.ObservedResult{Status: actionhook.StatusDenied, ObservedAt: time.Now().Unix(), ErrorCode: "wire_decision_blocked"}) + return intent, result, err + } + sender.mu.Lock() + if sender.attempts == nil { + sender.attempts = make(map[string]governedActionAttempt) + } + sender.attempts[intent.ID] = governedActionAttempt{envelope: envelope, preflight: preflight} + sender.mu.Unlock() + return intent, result, nil +} + +// complete records the actual transport result exactly once. A post-hook +// failure is evidence loss, never authority to repeat a send. +func (sender *governedOutboundSender) complete(ctx context.Context, intentID string, succeeded bool, failureCode string) { + sender.completeWithResponse(ctx, intentID, succeeded, failureCode, "", nil) +} + +func (sender *governedOutboundSender) completeWithResponse(ctx context.Context, intentID string, succeeded bool, failureCode, contentType string, response []byte) { + if sender == nil || sender.hook == nil || intentID == "" { + return + } + sender.mu.Lock() + attempt, exists := sender.attempts[intentID] + delete(sender.attempts, intentID) + delete(sender.disclosures, intentID) + sender.mu.Unlock() + if !exists { + return + } + status := actionhook.StatusSucceeded + if !succeeded { + status = actionhook.StatusFailed + if failureCode == "" { + failureCode = "transport_failed" + } + } + observed := actionhook.ObservedResult{Status: status, ObservedAt: time.Now().Unix(), ErrorCode: failureCode} + if len(response) > 0 && contentType != "" && sender.contentBuilder != nil { + content, err := sender.contentBuilder.NewOutboundFederatedResponseContent(contentType, "", response) + if err != nil { + slog.Error("governed transport response attachment failed", "intent_id", intentID, "error", err) + } else { + observed.FederatedContent = &content + } + } + if err := sender.hook.AfterAction(ctx, attempt.envelope, attempt.preflight, observed); err != nil { + slog.Error("governed transport post-hook failed", "intent_id", intentID, "error", err) + } +} + +func (sender *governedOutboundSender) disclosure(intentID string) (decision.DisclosureBinding, bool) { + if sender == nil || intentID == "" { + return decision.DisclosureBinding{}, false + } + sender.mu.Lock() + defer sender.mu.Unlock() + disclosure, found := sender.disclosures[intentID] + disclosure.Labels = append([]string(nil), disclosure.Labels...) + return disclosure, found +} + +func frameContentType(frameType uint32) string { + switch frameType { + case dataexchange.TypeText: + return "text/plain" + case dataexchange.TypeJSON: + return "application/json" + case dataexchange.TypeFile, dataexchange.TypeBinary: + return "application/octet-stream" + default: + return "application/octet-stream" + } +} + +func outboundDecisionPermits(result decision.Decision) error { + switch result.Outcome { + case decision.Allow, decision.Constrain: + return nil + case decision.Deny: + return fmt.Errorf("enterprise decision denied%s", decisionReasonSuffix(result.Reasons)) + case decision.ApprovalRequired: + return fmt.Errorf("enterprise decision requires approval%s", decisionReasonSuffix(result.Reasons)) + default: + return fmt.Errorf("enterprise decision has unsupported outcome %q", result.Outcome) + } +} + +func decisionReasonSuffix(reasons []string) string { + if len(reasons) == 0 { + return "" + } + return ": " + strings.Join(reasons, "; ") +} diff --git a/cmd/pilotctl/governed_transport_test.go b/cmd/pilotctl/governed_transport_test.go new file mode 100644 index 00000000..0dbca764 --- /dev/null +++ b/cmd/pilotctl/governed_transport_test.go @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "bytes" + "context" + "os" + "strings" + "testing" + "time" + + "github.com/pilot-protocol/common/decision" + "github.com/pilot-protocol/dataexchange" +) + +type outboundDecisionCall struct { + action string + resource string + payloadHash string +} + +type fakeOutboundDecisionRequester struct { + outcome decision.Outcome + calls []outboundDecisionCall +} + +func (requester *fakeOutboundDecisionRequester) HasOutboundDecisions() bool { return true } + +func (requester *fakeOutboundDecisionRequester) AuthorizeOutbound(_ context.Context, action, resource, payloadHash string) (decision.Intent, decision.Decision, error) { + requester.calls = append(requester.calls, outboundDecisionCall{action: action, resource: resource, payloadHash: payloadHash}) + now := time.Now().UTC() + intent := decision.Intent{ + Version: decision.SchemaVersion, ID: "intent-governed-test", TenantID: "tenant-a", AgentID: "sender-a", + Action: action, Resource: resource, PayloadHash: payloadHash, Risk: decision.RiskHigh, + IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), Nonce: strings.Repeat("a", 32), KeyID: "sender-key", Signature: "signed", + } + intentHash, err := intent.Hash() + if err != nil { + return decision.Intent{}, decision.Decision{}, err + } + result := decision.Decision{ + Version: decision.SchemaVersion, ID: "decision-governed-test", IntentHash: intentHash, + TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: requester.outcome, + PolicyRevision: 1, RevocationEpoch: 1, ProviderID: "authority-a", IssuedAt: now.Unix(), ExpiresAt: intent.ExpiresAt, KeyID: "authority-key", Signature: "signed", + } + if requester.outcome == decision.Deny { + result.Reasons = []string{"policy:blocked"} + } + return intent, result, nil +} + +func TestGovernedOutboundFrameRequestsExactActionAndPayload(t *testing.T) { + requester := &fakeOutboundDecisionRequester{outcome: decision.Allow} + sender := &governedOutboundSender{requester: requester, resource: "agent:finance/inbox"} + frame := &dataexchange.Frame{Type: dataexchange.TypeJSON, Payload: []byte(`{"amount":25}`)} + intent, result, err := sender.authorizeFrame(context.Background(), frame) + if err != nil { + t.Fatal(err) + } + if result.Outcome != decision.Allow || len(requester.calls) != 1 { + t.Fatalf("result=%+v calls=%+v", result, requester.calls) + } + call := requester.calls[0] + if call.action != "data.send.json" || call.resource != "agent:finance/inbox" { + t.Fatalf("call=%+v", call) + } + if want := dataexchange.GovernedPayloadHash(frame.Type, frame.Filename, frame.Payload); call.payloadHash != want || intent.PayloadHash != want { + t.Fatalf("payload hash=%q/%q, want %q", call.payloadHash, intent.PayloadHash, want) + } +} + +func TestGovernedOutboundDenyDoesNotPermitFrame(t *testing.T) { + requester := &fakeOutboundDecisionRequester{outcome: decision.Deny} + sender := &governedOutboundSender{requester: requester, resource: "agent:finance/inbox"} + _, result, err := sender.authorizeFrame(context.Background(), &dataexchange.Frame{Type: dataexchange.TypeText, Payload: []byte("blocked")}) + if err == nil || !strings.Contains(err.Error(), "denied") { + t.Fatalf("err=%v, want deny", err) + } + if result.Outcome != decision.Deny || len(requester.calls) != 1 { + t.Fatalf("result=%+v calls=%+v", result, requester.calls) + } +} + +func TestCmdSendMessageWritesGovernedEnvelope(t *testing.T) { + requester := &fakeOutboundDecisionRequester{outcome: decision.Allow} + previous := loadOutboundDecisionRequester + loadOutboundDecisionRequester = func(path string) (outboundDecisionRequester, error) { + if path != "control.json" { + t.Errorf("control path=%q", path) + } + return requester, nil + } + defer func() { loadOutboundDecisionRequester = previous }() + + sd := newStreamDaemon(t) + sd.useDaemonNoRegistry(t) + out := captureStdout(t, func() { + withJSON(func() { + cmdSendMessage([]string{ + "0:0000.0000.002A", "--data", "govern this", "--enterprise-control", "control.json", "--governed-resource", "agent:finance/inbox", + }) + }) + }) + if !strings.Contains(out, `"governed":true`) { + t.Fatalf("governed result missing: %s", out) + } + payload := capturedDataExchangePayload(sd) + if len(payload) == 0 { + t.Fatal("no data-exchange frame captured") + } + frame, err := dataexchange.ReadFrame(bytes.NewReader(payload)) + if err != nil { + t.Fatalf("read governed frame (%d bytes): %v", len(payload), err) + } + governed, err := dataexchange.DecodeGovernedFrame(frame) + if err != nil { + t.Fatalf("decode governed frame: %v", err) + } + if governed.Intent.Action != "data.send.text" || governed.Intent.Resource != "agent:finance/inbox" || string(governed.Payload) != "govern this" { + t.Fatalf("governed=%+v", governed) + } +} + +func TestCmdSendFileWritesGovernedEnvelope(t *testing.T) { + requester := &fakeOutboundDecisionRequester{outcome: decision.Allow} + previous := loadOutboundDecisionRequester + loadOutboundDecisionRequester = func(string) (outboundDecisionRequester, error) { return requester, nil } + defer func() { loadOutboundDecisionRequester = previous }() + + sd := newStreamDaemon(t) + sd.useDaemonNoRegistry(t) + path := t.TempDir() + "/budget.csv" + if err := os.WriteFile(path, []byte("approved,file"), 0o600); err != nil { + t.Fatal(err) + } + out := captureStdout(t, func() { + withJSON(func() { + cmdSendFile([]string{ + "0:0000.0000.002A", path, "--no-stream", "--enterprise-control", "control.json", "--governed-resource", "agent:finance/inbox", + }) + }) + }) + if !strings.Contains(out, `"governed":true`) { + t.Fatalf("governed result missing: %s", out) + } + frame, err := dataexchange.ReadFrame(bytes.NewReader(capturedDataExchangePayload(sd))) + if err != nil { + t.Fatalf("read governed file frame: %v", err) + } + governed, err := dataexchange.DecodeGovernedFrame(frame) + if err != nil { + t.Fatalf("decode governed file frame: %v", err) + } + if governed.Type != dataexchange.TypeFile || governed.Intent.Action != "file.share" || governed.Filename != "budget.csv" || string(governed.Payload) != "approved,file" { + t.Fatalf("governed file=%+v", governed) + } +} + +func capturedDataExchangePayload(sd *streamDaemon) []byte { + sd.capturedMu.Lock() + defer sd.capturedMu.Unlock() + for _, frames := range sd.captured { + if len(frames) > 1 { + var payload []byte + for _, part := range frames { + payload = append(payload, part...) + } + return payload + } + } + return nil +} + +func TestGovernedOutboundRequiresReceiverResource(t *testing.T) { + previous := loadOutboundDecisionRequester + defer func() { loadOutboundDecisionRequester = previous }() + if _, err := governedOutboundFromFlags(map[string]string{"enterprise-control": "control.json"}); err == nil || !strings.Contains(err.Error(), "governed-resource") { + t.Fatalf("err=%v, want receiver-resource requirement", err) + } +} + +func TestGovernedOutboundCanBeExplicitlyEnabledByEnvironment(t *testing.T) { + t.Setenv("PILOT_ENTERPRISE_CONTROL", "control.json") + t.Setenv("PILOT_GOVERNED_RESOURCE", "agent:finance/inbox") + requester := &fakeOutboundDecisionRequester{outcome: decision.Allow} + previous := loadOutboundDecisionRequester + loadOutboundDecisionRequester = func(path string) (outboundDecisionRequester, error) { + if path != "control.json" { + t.Fatalf("path = %q", path) + } + return requester, nil + } + t.Cleanup(func() { loadOutboundDecisionRequester = previous }) + sender, err := governedOutboundFromFlags(map[string]string{}) + if err != nil || sender == nil || sender.resource != "agent:finance/inbox" { + t.Fatalf("sender=%+v err=%v", sender, err) + } +} diff --git a/cmd/pilotctl/main.go b/cmd/pilotctl/main.go index 9b53f576..d95d0f82 100644 --- a/cmd/pilotctl/main.go +++ b/cmd/pilotctl/main.go @@ -4,6 +4,7 @@ package main import ( "bufio" + "context" cryptorand "crypto/rand" "encoding/binary" "encoding/json" @@ -24,6 +25,7 @@ import ( "time" "github.com/pilot-protocol/common/consent" + "github.com/pilot-protocol/common/decision" "github.com/pilot-protocol/common/driver" "github.com/pilot-protocol/common/protocol" registry "github.com/pilot-protocol/common/registry/client" @@ -869,6 +871,7 @@ func hasHelpFlag(args []string) bool { // commandHelp holds concise usage text for each command. Looked up by // printCommandHelp when the user passes -h / --help after a command name. var commandHelp = map[string]string{ + "enterprise": enterpriseHelpText, "send-message": `Usage: pilotctl send-message --data [flags] Send a message to a remote agent and optionally wait for the reply. @@ -881,6 +884,8 @@ Flags: --wait [] wait for a reply in the inbox (default timeout: 30s) --trace print per-step timing breakdown to stderr --no-auto-handshake skip automatic trust handshake with known agents + --enterprise-control request a signed enterprise decision before sending + --governed-resource exact receiver-owned resource bound in that decision Examples: pilotctl send-message list-agents --data '/data {"search":"weather","limit":5}' @@ -1530,7 +1535,7 @@ Communication commands: pilotctl connect [port] [--message ] [--timeout ] pilotctl send --data [--timeout ] pilotctl recv [--count ] [--timeout ] - pilotctl send-file + pilotctl send-file [--enterprise-control --governed-resource ] pilotctl send-message --data [--type text|json|binary] [--count ] [--reuse-conn] [--wait ] pilotctl dgram --data pilotctl subscribe [--count ] [--timeout ] @@ -1588,6 +1593,7 @@ Updates: pilotctl updates [--count ] [--scope ] read the Pilot changelog feed Operator / admin (run 'pilotctl extras' or 'pilotctl context' for the full list): + pilotctl enterprise status|dashboard-url|policy|mandate|receipt --endpoint --tenant inspect or submit signed enterprise control state pilotctl extras network / managed / policy / member-tags / enterprise / low-level plumbing pilotctl extras gateway start|stop|map|unmap|list IP gateway (requires root — creates loopback interface aliases) @@ -1705,6 +1711,10 @@ dispatch: cmdReview(cmdArgs) return + case "enterprise": + cmdEnterprise(cmdArgs) + return + // Bootstrap case "init": cmdInit(cmdArgs) @@ -4257,6 +4267,10 @@ func cmdSendFile(args []string) { fatalCode("invalid_argument", "%s is a directory, not a file", filePath) } size := fi.Size() + governedOutbound, governedErr := governedOutboundFromFlags(flags) + if governedErr != nil { + fatalCode("invalid_argument", "governed send-file: %v", governedErr) + } // Streamed transfer (default): chunked, ACK'd, resumable, end-to-end // SHA-256 verified — no per-frame size cap, and big files no longer @@ -4265,9 +4279,11 @@ func cmdSendFile(args []string) { // the single-frame TypeFile path when the receiver is too old to // understand TypeFileStream (it never sends an INIT-ACK). if !flagBool(flags, "no-stream") { - if res, serr := streamSendFile(d, target, filePath, filename, size, timeout); serr == nil { + if res, serr := streamSendFile(d, target, filePath, filename, size, timeout, governedOutbound); serr == nil { outputOK(res) return + } else if governedOutbound != nil && errors.Is(serr, dataexchange.ErrStreamUnsupported) { + fatalCode("connection_failed", "governed send-file requires a receiver that supports governed streaming; refusing legacy fallback") } else if !errors.Is(serr, dataexchange.ErrStreamUnsupported) { fatalHint("connection_failed", "check reachability: pilotctl ping "+target.String()+" · for very large/slow links raise --timeout", @@ -4310,7 +4326,27 @@ func cmdSendFile(args []string) { stop := startWaitProgress(fmt.Sprintf("sending %s to %s", filename, target)) start := time.Now() - if err := client.SendFile(filename, data); err != nil { + var governedDecision decision.Decision + var governedIntent decision.Intent + if governedOutbound != nil { + frame := &dataexchange.Frame{Type: dataexchange.TypeFile, Filename: filename, Payload: data} + intent, result, authorizeErr := governedOutbound.authorizeFrame(context.Background(), frame) + if authorizeErr != nil { + stop() + fatalCode("permission_denied", "governed send-file: %v", authorizeErr) + } + governedIntent = intent + governedDecision = result + if disclosure, found := governedOutbound.disclosure(intent.ID); found { + err = client.SendGovernedWithDisclosure(frame, intent, result, disclosure) + } else { + err = client.SendGoverned(frame, intent, result) + } + } else { + err = client.SendFile(filename, data) + } + if err != nil { + governedOutbound.complete(context.Background(), governedIntent.ID, false, "transport_send_failed") stop() fatalCode("connection_failed", "send failed: %v", err) } @@ -4336,6 +4372,7 @@ func cmdSendFile(args []string) { case res := <-ackCh: ack = res.frame if res.err != nil { + governedOutbound.complete(context.Background(), governedIntent.ID, false, "ack_unavailable") stop() // Sender wrote all bytes but never got the receiver's ACK // back (likely receiver crashed or restarted mid-transfer). @@ -4344,6 +4381,7 @@ func cmdSendFile(args []string) { "send wrote all bytes but no ACK from receiver: %v", res.err) } case <-time.After(timeout): + governedOutbound.complete(context.Background(), governedIntent.ID, false, "ack_timeout") stop() // Closing the conn lets the goroutine unwind. We deliberately // don't wait for it here — we've already given the receiver its @@ -4368,6 +4406,11 @@ func cmdSendFile(args []string) { "elapsed_ms": elapsed.Milliseconds(), "throughput_mbps": mbps, } + if governedOutbound != nil { + result["governed"] = true + result["decision_id"] = governedDecision.ID + result["policy_revision"] = governedDecision.PolicyRevision + } if ack != nil { ackText := string(ack.Payload) result["ack"] = ackText @@ -4375,9 +4418,15 @@ func cmdSendFile(args []string) { // with "ERR " — surface them as a real failure instead of // claiming success (e.g. disk-full, save permission denied). if strings.HasPrefix(ackText, "ERR ") { + governedOutbound.completeWithResponse(context.Background(), governedIntent.ID, false, "receiver_rejected", "text/plain", ack.Payload) fatalCode("internal", "receiver rejected file: %s", ackText) } } + if ack != nil { + governedOutbound.completeWithResponse(context.Background(), governedIntent.ID, true, "", frameContentType(ack.Type), ack.Payload) + } else { + governedOutbound.complete(context.Background(), governedIntent.ID, true, "") + } outputOK(result) } @@ -4386,7 +4435,7 @@ func cmdSendFile(args []string) { // dataexchange.ErrStreamUnsupported tells the caller to fall back to the // single-frame TypeFile path (the receiver is too old). timeout bounds the // wait for any single ACK and for the receiver's final verification. -func streamSendFile(d *driver.Driver, target protocol.Addr, filePath, filename string, size int64, timeout time.Duration) (map[string]interface{}, error) { +func streamSendFile(d *driver.Driver, target protocol.Addr, filePath, filename string, size int64, timeout time.Duration, governedOutbound *governedOutboundSender) (map[string]interface{}, error) { client, err := dataexchange.Dial(d, target) if err != nil { return nil, err @@ -4401,21 +4450,60 @@ func streamSendFile(d *driver.Driver, target protocol.Addr, filePath, filename s stop := startWaitProgress(fmt.Sprintf("streaming %s to %s", filename, target)) start := time.Now() - res, serr := client.SendFileStream(filename, f, size, timeout) + var governedDecision decision.Decision + var governedIntent decision.Intent + var res *dataexchange.StreamResult + var serr error + if governedOutbound != nil { + if governedOutbound.hook != nil && governedOutbound.contentBuilder != nil { + if size > maxInlineHostedFederationBytes { + return nil, fmt.Errorf("hosted federation currently accepts files up to %d bytes; use an unmanaged transfer or split the file", maxInlineHostedFederationBytes) + } + body, readErr := io.ReadAll(io.LimitReader(f, maxInlineHostedFederationBytes+1)) + if readErr != nil { + return nil, readErr + } + if _, seekErr := f.Seek(0, io.SeekStart); seekErr != nil { + return nil, seekErr + } + res, serr = client.SendGovernedFileStreamWithDisclosureAuthorizer(filename, f, size, func(initPayload []byte) (decision.Intent, decision.Decision, decision.DisclosureBinding, error) { + intent, result, disclosure, authorizeErr := governedOutbound.authorizeFederatedStream(context.Background(), initPayload, body) + if authorizeErr == nil { + governedIntent = intent + governedDecision = result + } + return intent, result, disclosure, authorizeErr + }, timeout) + } else { + res, serr = client.SendGovernedFileStreamWithAuthorizer(filename, f, size, func(initPayload []byte) (decision.Intent, decision.Decision, error) { + intent, result, authorizeErr := governedOutbound.authorizeStream(context.Background(), initPayload) + if authorizeErr == nil { + governedIntent = intent + governedDecision = result + } + return intent, result, authorizeErr + }, timeout) + } + } else { + res, serr = client.SendFileStream(filename, f, size, timeout) + } stop() if serr != nil { + governedOutbound.complete(context.Background(), governedIntent.ID, false, "stream_failed") return nil, serr } if !res.OK { + governedOutbound.complete(context.Background(), governedIntent.ID, false, "receiver_rejected") return nil, fmt.Errorf("receiver rejected file: %s", res.Message) } + governedOutbound.completeWithResponse(context.Background(), governedIntent.ID, true, "", "text/plain", []byte(res.Message)) elapsed := time.Since(start) mbps := 0.0 if elapsed > 0 { mbps = (float64(res.TotalBytes) * 8.0) / (1e6 * elapsed.Seconds()) } - return map[string]interface{}{ + result := map[string]interface{}{ "filename": filename, "bytes": res.TotalBytes, "bytes_sent": res.BytesSent, @@ -4426,13 +4514,19 @@ func streamSendFile(d *driver.Driver, target protocol.Addr, filePath, filename s "throughput_mbps": mbps, "transport": "filestream", "verified": res.OK, - }, nil + } + if governedOutbound != nil { + result["governed"] = true + result["decision_id"] = governedDecision.ID + result["policy_revision"] = governedDecision.PolicyRevision + } + return result, nil } func cmdSendMessage(args []string) { flags, pos := parseFlags(args) if len(pos) < 1 { - fatalCode("invalid_argument", "usage: pilotctl send-message --data [--type text|json|binary] [--trace] [--count ] [--reuse-conn] [--wait ]") + fatalCode("invalid_argument", "usage: pilotctl send-message --data [--type text|json|binary] [--trace] [--count ] [--reuse-conn] [--wait ] [--enterprise-control --governed-resource ]") } sendCount := flagInt(flags, "count", 1) @@ -4501,6 +4595,13 @@ func cmdSendMessage(args []string) { if innerType == 0 && msgType != "text" { fatalCode("invalid_argument", "unknown type %q (use text, json, or binary)", msgType) } + governedOutbound, governedErr := governedOutboundFromFlags(flags) + if governedErr != nil { + fatalCode("invalid_argument", "governed send-message: %v", governedErr) + } + if governedOutbound != nil && traceTime { + fatalCode("invalid_argument", "--trace is unavailable with --enterprise-control because trace frames are not governed") + } // dialOnce opens a fresh data-exchange connection and returns it. Used // both for single sends and for the no-reuse multi-send path. @@ -4522,7 +4623,20 @@ func cmdSendMessage(args []string) { sendOne := func(cl *dataexchange.Client, seq int, reused bool) map[string]interface{} { var sentAtNs int64 var sendErr error - if traceTime { + var governedIntent decision.Intent + var governedDecision decision.Decision + if governedOutbound != nil { + frame := &dataexchange.Frame{Type: innerType, Payload: []byte(data)} + governedIntent, governedDecision, sendErr = governedOutbound.authorizeFrame(context.Background(), frame) + if sendErr == nil { + if disclosure, found := governedOutbound.disclosure(governedIntent.ID); found { + sendErr = cl.SendGovernedWithDisclosure(frame, governedIntent, governedDecision, disclosure) + } else { + sendErr = cl.SendGoverned(frame, governedIntent, governedDecision) + } + } + sentAtNs = time.Now().UnixNano() + } else if traceTime { sentAtNs, sendErr = cl.SendTrace(innerType, []byte(data)) } else { sendStart := time.Now() @@ -4537,6 +4651,7 @@ func cmdSendMessage(args []string) { sentAtNs = sendStart.UnixNano() } if sendErr != nil { + governedOutbound.complete(context.Background(), governedIntent.ID, false, "transport_send_failed") return map[string]interface{}{"seq": seq, "error": sendErr.Error()} } @@ -4544,6 +4659,15 @@ func cmdSendMessage(args []string) { ackRecvAtNs := time.Now().UnixNano() if ackErr != nil { slog.Debug("send-message ACK read failed", "err", ackErr) + governedOutbound.complete(context.Background(), governedIntent.ID, false, "ack_unavailable") + } else if ack != nil && strings.HasPrefix(string(ack.Payload), "ERR ") { + governedOutbound.completeWithResponse(context.Background(), governedIntent.ID, false, "receiver_rejected", frameContentType(ack.Type), ack.Payload) + } else { + if ack != nil { + governedOutbound.completeWithResponse(context.Background(), governedIntent.ID, true, "", frameContentType(ack.Type), ack.Payload) + } else { + governedOutbound.complete(context.Background(), governedIntent.ID, true, "") + } } r := map[string]interface{}{ @@ -4551,6 +4675,11 @@ func cmdSendMessage(args []string) { "bytes": len(data), "reused": reused, } + if governedOutbound != nil && sendErr == nil { + r["governed"] = true + r["decision_id"] = governedDecision.ID + r["policy_revision"] = governedDecision.PolicyRevision + } if ack != nil { r["ack"] = string(ack.Payload) } @@ -4593,6 +4722,11 @@ func cmdSendMessage(args []string) { tracef("dataexchange.Dial") defer cl.Close() r := sendOne(cl, 0, false) + if governedOutbound != nil { + if message, failed := r["error"].(string); failed { + fatalCode("permission_denied", "governed send-message: %s", message) + } + } result := map[string]interface{}{ "target": target.String(), "to": target.String(), @@ -4641,7 +4775,13 @@ func cmdSendMessage(args []string) { defer cl.Close() var results []map[string]interface{} for i := 0; i < sendCount; i++ { - results = append(results, sendOne(cl, i, i > 0)) + result := sendOne(cl, i, i > 0) + if governedOutbound != nil { + if message, failed := result["error"].(string); failed { + fatalCode("permission_denied", "governed send-message: %s", message) + } + } + results = append(results, result) if i < sendCount-1 { time.Sleep(50 * time.Millisecond) } @@ -4659,7 +4799,14 @@ func cmdSendMessage(args []string) { var results []map[string]interface{} for i := 0; i < sendCount; i++ { cl := dialOnce() - results = append(results, sendOne(cl, i, false)) + result := sendOne(cl, i, false) + if governedOutbound != nil { + if message, failed := result["error"].(string); failed { + _ = cl.Close() + fatalCode("permission_denied", "governed send-message: %s", message) + } + } + results = append(results, result) cl.Close() if i < sendCount-1 { time.Sleep(50 * time.Millisecond) diff --git a/cmd/pilotctl/skills.go b/cmd/pilotctl/skills.go index e8f95bd0..585a9bca 100644 --- a/cmd/pilotctl/skills.go +++ b/cmd/pilotctl/skills.go @@ -62,13 +62,8 @@ func runTick() (*skillinject.Report, error) { return skillinject.ForceTick(ctx, skillinject.Config{}) } -// planTick performs a read-only dry run: same manifest fetch + classification -// as a real tick, but writes nothing to disk. Each Outcome carries the current -// on-disk State and the Action the next real tick WOULD take. Use this for -// display surfaces (status, paths, info summary) so that merely *looking* at -// skill state never mutates the filesystem — and never reports a file as -// "absent — next: create" in the same breath that a mutating tick just created -// it (the pre-write-state skew that ForceTick-backed status suffered from). +// planTick performs a read-only dry run. Disabled mode returns without remote +// access; enabled modes fetch and classify without writing to disk. func planTick() (*skillinject.Report, error) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() diff --git a/cmd/pilotctl/zz_more_cmds_test.go b/cmd/pilotctl/zz_more_cmds_test.go index 4e3c5acd..ddeca9c0 100644 --- a/cmd/pilotctl/zz_more_cmds_test.go +++ b/cmd/pilotctl/zz_more_cmds_test.go @@ -124,6 +124,7 @@ func TestCommandHelpRegistryHasCriticalKeys(t *testing.T) { "listen", "find", "handshake", "peers", "inbox", "info", "health", "daemon start", "daemon stop", "daemon status", "init", "config", "version", "context", + "enterprise", "network", "trust", "trusted", "pending", "approve", "reject", "untrust", "appstore", } diff --git a/internal/enterprisecontrol/action_hook.go b/internal/enterprisecontrol/action_hook.go new file mode 100644 index 00000000..3b61b34f --- /dev/null +++ b/internal/enterprisecontrol/action_hook.go @@ -0,0 +1,447 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package enterprisecontrol + +import ( + "bytes" + "context" + "crypto/ed25519" + "errors" + "fmt" + "time" + + "github.com/pilot-protocol/common/actioncontinuation" + "github.com/pilot-protocol/common/actionhook" + "github.com/pilot-protocol/common/actionregistry" + "github.com/pilot-protocol/common/authority" + "github.com/pilot-protocol/common/decision" + "github.com/pilot-protocol/common/decisionhttp" +) + +type actionHookState struct { + selected bool + managed bool + federated bool + exchangeID string + intent decision.Intent + result decision.Decision + continuationID string + continuationLease string +} + +// ActionHook returns this attachment as a universal hook only when an +// operator explicitly selected a non-off profile. A nil return is the hard +// compatibility boundary used by unmanaged nodes. +func (runtime *Runtime) ActionHook() actionhook.Hook { + if runtime == nil || runtime.actionRegistry == nil || runtime.actionProfile.Mode.Normalize() == actionregistry.ModeOff { + return nil + } + return runtime +} + +// ActionArtifacts returns the locally verified signed wire objects behind a +// managed preflight. Governed transport adapters need these exact objects for +// receiver-side verification; local/observe-only hooks intentionally expose +// none. +func (runtime *Runtime) ActionArtifacts(preflight actionhook.Preflight) (decision.Intent, decision.Decision, bool) { + state, ok := preflight.State.(actionHookState) + if !ok || !state.selected || !state.managed || state.intent.ID == "" || state.result.ID == "" { + return decision.Intent{}, decision.Decision{}, false + } + return state.intent, state.result, true +} + +// BeforeAction evaluates only explicitly selected actions. Local enforcement +// uses the signed policy bundle already installed on the node; managed +// enforcement obtains and verifies a fresh signed authority Decision. Observe +// mode evaluates locally but cannot block the adapter. +func (runtime *Runtime) BeforeAction(ctx context.Context, envelope actionhook.Envelope) (actionhook.Preflight, error) { + if err := envelope.Validate(); err != nil { + return actionhook.Preflight{}, err + } + runtime.mu.Lock() + registry := runtime.actionRegistry + profile := runtime.actionProfile + agentID := runtime.actionAgentID + risk := runtime.actionRisk + localPolicy := runtime.localPolicy + tenantID := runtime.tenantID + continuations := runtime.continuations + fleetControl, fleetControlled := runtime.fleetControl, runtime.fleetControlFound + runtime.mu.Unlock() + if fleetControlled && fleetControl.Quarantined { + if profile.Mode.Normalize() == actionregistry.ModeObserve { + return actionhook.Preflight{Outcome: decision.Allow, Reasons: []string{"observe:fleet_quarantine"}, ObserveOnly: true, State: actionHookState{selected: true}}, nil + } + return actionhook.Preflight{Outcome: decision.Deny, Reasons: []string{"fleet_quarantine"}, State: actionHookState{selected: true}}, nil + } + if registry == nil || !profile.AppliesTo(registry, envelope.Action) { + return actionhook.Preflight{Outcome: decision.Allow, ObserveOnly: true, State: actionHookState{}}, nil + } + definition, found := registry.Resolve(envelope.Action) + if !found { + return actionhook.Preflight{}, fmt.Errorf("enterprise control: action %q is not registered", envelope.Action) + } + canonical := definition.Name + + var intent decision.Intent + var result decision.Decision + managed := profile.Mode.Normalize() == actionregistry.ModeManagedEnforce + if managed { + if definition.Privacy == actionregistry.PrivacyFederatedContent && envelope.FederatedContent == nil { + return actionhook.Preflight{}, fmt.Errorf("enterprise control: managed action %q requires hosted federation content", canonical) + } + if definition.Resumable && continuations != nil { + resumed, exists, resumeErr := runtime.resumeAction(ctx, envelope, canonical) + if resumeErr != nil || exists { + return resumed, resumeErr + } + } + var err error + if envelope.FederatedContent != nil { + var exchange decisionhttp.FederationExchangeResponse + intent, exchange, err = runtime.AuthorizeOutboundFederatedContent(ctx, canonical, envelope.Resource, envelope.FederatedContent.Clone()) + result = exchange.Decision + if err == nil { + envelope.PayloadHash = intent.PayloadHash + } + if err != nil { + return actionhook.Preflight{}, err + } + preflight := actionhook.Preflight{ + Outcome: result.Outcome, Reasons: append([]string(nil), result.Reasons...), + Constraints: append([]decision.Constraint(nil), result.Constraints...), + Reference: actionhook.DecisionReference{ + IntentID: intent.ID, DecisionID: result.ID, PolicyRevision: result.PolicyRevision, ProviderID: result.ProviderID, + ExchangeID: exchange.ExchangeID, + ApprovalTransaction: exchange.ApprovalTransactionID, ApprovalExpiresAt: exchange.ApprovalExpiresAt, + }, + State: actionHookState{selected: true, managed: true, federated: true, exchangeID: exchange.ExchangeID, intent: intent, result: result}, + } + if result.Outcome == decision.ApprovalRequired && definition.Resumable && continuations != nil { + pending, pendingErr := actioncontinuation.NewPending(tenantID, agentID, envelope, preflight.Reference, exchange.ApprovalTransactionID, time.Unix(exchange.ApprovalExpiresAt, 0)) + if pendingErr != nil { + return actionhook.Preflight{}, pendingErr + } + stored, storeErr := continuations.PutPending(ctx, pending) + if storeErr != nil { + return actionhook.Preflight{}, fmt.Errorf("enterprise control: persist hosted approval continuation: %w", storeErr) + } + preflight.Reference.ApprovalTransaction = stored.ApprovalTransaction + preflight.Reference.ApprovalExpiresAt = stored.ExpiresAt + } + return preflight, nil + } + intent, result, err = runtime.AuthorizeOutbound(ctx, canonical, envelope.Resource, envelope.PayloadHash) + if err != nil { + return actionhook.Preflight{}, err + } + } else { + if localPolicy == nil { + return actionhook.Preflight{}, fmt.Errorf("enterprise control: local action policy is unavailable") + } + intent = decision.Intent{ + Version: decision.SchemaVersion, ID: envelope.ID, TenantID: tenantID, AgentID: agentID, + Action: canonical, Resource: envelope.Resource, PayloadHash: envelope.PayloadHash, Risk: risk, + } + var err error + result, err = localPolicy.Authorize(ctx, intent) + if err != nil { + if profile.Mode.Normalize() == actionregistry.ModeObserve { + return actionhook.Preflight{ + Outcome: decision.Allow, Reasons: []string{"observe:evaluation_unavailable"}, ObserveOnly: true, + State: actionHookState{selected: true}, + }, nil + } + return actionhook.Preflight{}, fmt.Errorf("enterprise control: evaluate local action policy: %w", err) + } + } + preflight := actionhook.Preflight{ + Outcome: result.Outcome, Reasons: append([]string(nil), result.Reasons...), + Constraints: append([]decision.Constraint(nil), result.Constraints...), + Reference: actionhook.DecisionReference{ + IntentID: intent.ID, DecisionID: result.ID, PolicyRevision: result.PolicyRevision, ProviderID: result.ProviderID, + }, + ObserveOnly: profile.Mode.Normalize() == actionregistry.ModeObserve, + State: actionHookState{selected: true, managed: managed, intent: intent, result: result}, + } + if managed && result.Outcome == decision.ApprovalRequired && definition.Resumable && continuations != nil { + record, err := runtime.outboundClient.BeginWorkflow(ctx, decisionhttp.WorkflowBeginEnvelope{Intent: intent, Initial: result}) + if err != nil { + return actionhook.Preflight{}, fmt.Errorf("enterprise control: begin approval workflow: %w", err) + } + pending, err := actioncontinuation.NewPending(tenantID, agentID, envelope, preflight.Reference, record.Transaction.ID, time.Unix(record.Transaction.ExpiresAt, 0)) + if err != nil { + return actionhook.Preflight{}, err + } + stored, err := continuations.PutPending(ctx, pending) + if err != nil { + return actionhook.Preflight{}, fmt.Errorf("enterprise control: persist approval continuation: %w", err) + } + preflight.Reference.ApprovalTransaction = stored.ApprovalTransaction + preflight.Reference.ApprovalExpiresAt = stored.ExpiresAt + } + return preflight, nil +} + +func (runtime *Runtime) resumeAction(ctx context.Context, envelope actionhook.Envelope, canonicalAction string) (actionhook.Preflight, bool, error) { + runtime.mu.Lock() + store := runtime.continuations + client := runtime.outboundClient + tenantID, agentID := runtime.tenantID, runtime.actionAgentID + runtime.mu.Unlock() + if store == nil || client == nil { + return actionhook.Preflight{}, false, nil + } + fingerprint := actioncontinuation.Fingerprint(tenantID, agentID, canonicalAction, envelope.Resource, envelope.PayloadHash, envelope.AdapterID, envelope.ResumeToken) + continuation, err := store.FindActive(ctx, fingerprint) + if errors.Is(err, actioncontinuation.ErrNotFound) { + return actionhook.Preflight{}, false, nil + } + if err != nil { + return actionhook.Preflight{}, true, err + } + reference := continuation.InitialDecision + reference.ApprovalTransaction = continuation.ApprovalTransaction + reference.ApprovalExpiresAt = continuation.ExpiresAt + switch continuation.State { + case actioncontinuation.StateExecuting: + return actionhook.Preflight{}, true, fmt.Errorf("enterprise control: approval continuation %s is already executing", continuation.ID) + case actioncontinuation.StateFailed: + return actionhook.Preflight{}, true, fmt.Errorf("enterprise control: approval continuation %s is fail-closed after %s", continuation.ID, continuation.FailureCode) + case actioncontinuation.StatePending: + default: + return actionhook.Preflight{}, true, fmt.Errorf("enterprise control: approval continuation %s is not resumable", continuation.ID) + } + workflow, err := client.WorkflowStatus(ctx, continuation.ApprovalTransaction) + if err != nil { + return actionhook.Preflight{}, true, fmt.Errorf("enterprise control: approval status: %w", err) + } + if !workflowMatchesContinuation(workflow, continuation) { + return actionhook.Preflight{}, true, fmt.Errorf("enterprise control: approval workflow no longer matches local continuation") + } + if workflow.Cancellation != nil { + return actionhook.Preflight{ + Outcome: decision.Deny, Reasons: []string{"approval:cancelled"}, Reference: reference, + State: actionHookState{selected: true}, + }, true, nil + } + if workflow.Certificate == nil { + return actionhook.Preflight{ + Outcome: decision.ApprovalRequired, Reasons: []string{"approval:pending"}, Reference: reference, + State: actionHookState{selected: true}, + }, true, nil + } + claimed, lease, err := store.ClaimResume(ctx, continuation.ID) + if err != nil { + return actionhook.Preflight{}, true, fmt.Errorf("enterprise control: claim approval continuation: %w", err) + } + intent, err := runtime.newWorkflowExecutionIntent(ctx, workflow.Transaction, workflow.Certificate.ExpiresAt) + if err != nil { + _, _ = store.Finish(context.Background(), claimed.ID, lease, false, "intent_creation_failed") + return actionhook.Preflight{}, true, err + } + result, err := client.ExecuteWorkflow(ctx, workflow.Transaction.ID, intent) + if err != nil { + _, _ = store.Finish(context.Background(), claimed.ID, lease, false, "workflow_execution_failed") + return actionhook.Preflight{}, true, fmt.Errorf("enterprise control: execute approved workflow: %w", err) + } + if err := runtime.enforcer.Verify(ctx, intent, result); err != nil { + _, _ = store.Finish(context.Background(), claimed.ID, lease, false, "decision_verification_failed") + return actionhook.Preflight{}, true, fmt.Errorf("enterprise control: verify approved workflow decision: %w", err) + } + return actionhook.Preflight{ + Outcome: result.Outcome, Reasons: append([]string(nil), result.Reasons...), Constraints: append([]decision.Constraint(nil), result.Constraints...), + Reference: actionhook.DecisionReference{ + IntentID: intent.ID, DecisionID: result.ID, PolicyRevision: result.PolicyRevision, ProviderID: result.ProviderID, + ApprovalTransaction: continuation.ApprovalTransaction, ApprovalExpiresAt: continuation.ExpiresAt, + }, + State: actionHookState{ + selected: true, managed: true, federated: reference.ExchangeID != "", exchangeID: reference.ExchangeID, intent: intent, result: result, + continuationID: claimed.ID, continuationLease: lease, + }, + }, true, nil +} + +func workflowMatchesContinuation(workflow decisionhttp.WorkflowRecord, continuation actioncontinuation.Record) bool { + transaction := workflow.Transaction + return transaction.ID == continuation.ApprovalTransaction && transaction.TenantID == continuation.TenantID && + transaction.AgentID == continuation.AgentID && transaction.Action == continuation.Action && + transaction.Resource == continuation.Resource && transaction.PayloadHash == continuation.PayloadHash && + transaction.PolicyRevision == continuation.InitialDecision.PolicyRevision +} + +func (runtime *Runtime) newWorkflowExecutionIntent(ctx context.Context, transaction decision.ApprovalTransaction, certificateExpiresAt int64) (decision.Intent, error) { + if !runtime.HasOutboundDecisions() { + return decision.Intent{}, fmt.Errorf("enterprise control: outbound decisions are not configured") + } + nonce, err := decision.NewNonce() + if err != nil { + return decision.Intent{}, err + } + runtime.mu.Lock() + keyID := runtime.outboundKeyID + privateKey := append(ed25519.PrivateKey(nil), runtime.outboundPrivate...) + mandateID, audience, purpose := runtime.outboundMandateID, runtime.outboundAudience, runtime.outboundPurpose + runtime.mu.Unlock() + publicKey, keyErr := runtime.trust.IntentKey(ctx, transaction.TenantID, transaction.AgentID, keyID) + if keyErr != nil || !bytes.Equal(publicKey, privateKey.Public().(ed25519.PublicKey)) { + return decision.Intent{}, fmt.Errorf("enterprise control: workflow intent key is no longer active") + } + now := time.Now().UTC() + expiresAt := now.Add(2 * time.Minute).Unix() + if certificateExpiresAt < expiresAt { + expiresAt = certificateExpiresAt + } + if expiresAt <= now.Unix() { + return decision.Intent{}, fmt.Errorf("enterprise control: approval certificate has expired") + } + intent := decision.Intent{ + Version: decision.SchemaVersion, ID: "resume-" + nonce, + TenantID: transaction.TenantID, AgentID: transaction.AgentID, + Action: transaction.Action, Resource: transaction.Resource, PayloadHash: transaction.PayloadHash, Risk: transaction.Risk, + MandateID: mandateID, Audience: audience, Purpose: purpose, + IssuedAt: now.Unix(), ExpiresAt: expiresAt, Nonce: nonce, KeyID: keyID, + } + if err := intent.Sign(privateKey); err != nil { + return decision.Intent{}, err + } + return intent, nil +} + +// AfterAction writes signed enforcement evidence when receipt signing is +// configured. It intentionally does nothing for unselected/local-only +// actions. A caller must never retry a side effect because this method fails. +func (runtime *Runtime) AfterAction(ctx context.Context, envelope actionhook.Envelope, preflight actionhook.Preflight, observed actionhook.ObservedResult) error { + if err := envelope.Validate(); err != nil { + return err + } + if err := observed.Validate(); err != nil { + return err + } + state, ok := preflight.State.(actionHookState) + if !ok || !state.selected { + return nil + } + var continuationErr error + if state.continuationID != "" { + succeeded := observed.Status == actionhook.StatusSucceeded + failureCode := observed.ErrorCode + if failureCode == "" && !succeeded { + failureCode = "adapter_did_not_execute" + } + _, continuationErr = runtime.continuations.Finish(ctx, state.continuationID, state.continuationLease, succeeded, failureCode) + } + if !state.managed { + return continuationErr + } + var federationErr error + if state.federated { + federationErr = runtime.reportFederationResult(ctx, state, observed) + } + var enforcementResult decision.EnforcementResult + switch state.result.Outcome { + case decision.Deny: + enforcementResult = decision.Denied + case decision.ApprovalRequired: + enforcementResult = decision.ApprovalPending + case decision.Allow, decision.Constrain: + if observed.Status == actionhook.StatusSucceeded { + enforcementResult = decision.Enforced + } else { + enforcementResult = decision.Failed + } + default: + return fmt.Errorf("enterprise control: cannot receipt invalid outcome %q", state.result.Outcome) + } + var receiptErr error + if runtime.receipts != nil { + recorder := transportReceiptRecorder{signer: runtime.receipts, enforcementPoint: envelope.AdapterID} + receiptErr = recorder.RecordGovernedResultReceipt(ctx, state.intent, state.result, enforcementResult, time.Unix(observed.ObservedAt, 0)) + } + activityErr := runtime.recordActionActivity(ctx, envelope, state, enforcementResult, observed.ObservedAt) + return errors.Join(continuationErr, federationErr, receiptErr, activityErr) +} + +func (runtime *Runtime) reportFederationResult(ctx context.Context, state actionHookState, observed actionhook.ObservedResult) error { + if state.exchangeID == "" || state.intent.ID == "" || state.result.ID == "" { + return fmt.Errorf("enterprise control: hosted federation result is missing execution state") + } + var status decision.FederationResultStatus + errorCode := observed.ErrorCode + switch observed.Status { + case actionhook.StatusSucceeded: + status = decision.FederationResultSucceeded + case actionhook.StatusFailed: + status = decision.FederationResultFailed + if errorCode == "" { + errorCode = "adapter_failed" + } + case actionhook.StatusSkipped: + status = decision.FederationResultSkipped + case actionhook.StatusDenied: + status = decision.FederationResultDenied + case actionhook.StatusApprovalPending: + status = decision.FederationResultApprovalPending + default: + return fmt.Errorf("enterprise control: unsupported hosted federation result %q", observed.Status) + } + runtime.mu.Lock() + client := runtime.outboundClient + keyID := runtime.outboundKeyID + privateKey := append(ed25519.PrivateKey(nil), runtime.outboundPrivate...) + timeout := runtime.outboundTimeout + runtime.mu.Unlock() + if client == nil || keyID == "" || len(privateKey) != ed25519.PrivateKeySize { + return fmt.Errorf("enterprise control: hosted federation result client is unavailable") + } + var responseDisclosure *decision.DisclosureBinding + var responseContent *decision.FederatedContent + if observed.FederatedContent != nil { + cloned := observed.FederatedContent.Clone() + responseDisclosure = &cloned.Disclosure + responseContent = &cloned + } + result, err := decision.NewFederationResult(state.exchangeID, state.intent, state.result, status, errorCode, responseDisclosure, time.Unix(observed.ObservedAt, 0), keyID) + if err != nil { + return err + } + if err := result.Sign(privateKey); err != nil { + return err + } + reportContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + _, err = client.SubmitFederationExchangeResult(reportContext, decisionhttp.FederationExchangeResultRequest{ + Version: decisionhttp.FederationExchangeVersion, ExecutionIntent: state.intent, + Decision: state.result, Result: result, Content: responseContent, + }) + if err != nil { + return fmt.Errorf("enterprise control: report hosted federation result: %w", err) + } + return nil +} + +func (runtime *Runtime) recordActionActivity(ctx context.Context, envelope actionhook.Envelope, state actionHookState, enforcementResult decision.EnforcementResult, observedAt int64) error { + runtime.mu.Lock() + client := runtime.rolloutClient + keyID := runtime.outboundKeyID + privateKey := append(ed25519.PrivateKey(nil), runtime.outboundPrivate...) + runtime.mu.Unlock() + if client == nil || keyID == "" || len(privateKey) != ed25519.PrivateKeySize || state.intent.ID == "" || state.result.ID == "" { + return nil + } + duration := time.Duration(0) + if observedAt > envelope.CreatedAt { + duration = time.Duration(observedAt-envelope.CreatedAt) * time.Second + } + activity, err := authority.NewFleetActivity(state.intent, state.result, enforcementResult, duration, keyID, observedAt) + if err != nil { + return err + } + if err := activity.Sign(privateKey); err != nil { + return err + } + return client.ReportFleetActivity(ctx, activity) +} + +var _ actionhook.Hook = (*Runtime)(nil) diff --git a/internal/enterprisecontrol/control.go b/internal/enterprisecontrol/control.go new file mode 100644 index 00000000..4e3b0bd6 --- /dev/null +++ b/internal/enterprisecontrol/control.go @@ -0,0 +1,2164 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package enterprisecontrol loads the daemon-side attachment between a +// tenant's signed authority state and the governed data-service boundaries. +// It deliberately verifies static bootstrap files locally; distributing newer +// signed state is a separate rollout transport concern. +package enterprisecontrol + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/pilot-protocol/common/actioncontinuation" + "github.com/pilot-protocol/common/actionregistry" + "github.com/pilot-protocol/common/authority" + "github.com/pilot-protocol/common/authorityhttp" + "github.com/pilot-protocol/common/coreapi" + "github.com/pilot-protocol/common/decision" + "github.com/pilot-protocol/common/decisionhttp" + "github.com/pilot-protocol/common/decisionpolicy" + "github.com/pilot-protocol/dataexchange" + "github.com/pilot-protocol/eventstream" +) + +// Config is the daemon's local, signed-state bootstrap attachment. Root keys +// are pinned out of band; trust and policy files are authenticated before +// they become usable at any resource boundary. +type Config struct { + TenantID string `json:"tenant_id"` + RootKeyID string `json:"root_key_id"` + RootPublicKey string `json:"root_public_key"` + TrustBundlePath string `json:"trust_bundle_path"` + PolicyBundlePath string `json:"policy_bundle_path"` + DataExchange *DataExchangeRule `json:"data_exchange,omitempty"` + EventStream *EventStreamRule `json:"event_stream,omitempty"` + Mandates *MandateConfig `json:"mandates,omitempty"` + Receipts *ReceiptConfig `json:"receipts,omitempty"` + Rollout *RolloutConfig `json:"rollout,omitempty"` + Fleet *FleetConfig `json:"fleet,omitempty"` + OutboundDecisions *OutboundDecisionConfig `json:"outbound_decisions,omitempty"` + ActionControl *ActionControlConfig `json:"action_control,omitempty"` + ContentInspection *ContentInspectionConfig `json:"content_inspection,omitempty"` +} + +// ActionControl opts concrete node adapters into the universal before/after +// action hook. Omitting this block (or using mode off) preserves unmanaged +// behavior even when the same attachment is used for governed transports. +type ActionControlConfig struct { + Profile actionregistry.Profile `json:"profile"` + AgentID string `json:"agent_id,omitempty"` + Risk decision.RiskClass `json:"risk,omitempty"` + ContinuationDirectory string `json:"continuation_directory,omitempty"` +} + +// DataExchangeRule binds all inbound data-exchange receipts to one exact +// local resource. Deploy separate daemons or attachments for separate tenant +// destinations rather than accepting a sender-selected destination. +type DataExchangeRule struct { + RequireGoverned bool `json:"require_governed"` + RequireDisclosure bool `json:"require_disclosure,omitempty"` + RequireContentInspection bool `json:"require_content_inspection,omitempty"` + TransferQuota *TransferQuotaConfig `json:"transfer_quota,omitempty"` + Retention *DataRetentionConfig `json:"retention,omitempty"` + Resource string `json:"resource"` +} + +// DataRetentionConfig maps signed V2 retention classes to a receiver-local +// expiry duration. The state journal is owned by the data-exchange service; +// no sender-provided path or duration is accepted. +type DataRetentionConfig struct { + Classes []DataRetentionClass `json:"classes"` + SweepIntervalSeconds int64 `json:"sweep_interval_seconds,omitempty"` +} + +type DataRetentionClass struct { + Class string `json:"class"` + RetainForSeconds int64 `json:"retain_for_seconds"` +} + +// EventStreamRule requires topic-bound resources. ResourceTemplate must +// contain the literal {topic} once, ensuring authority for one topic cannot +// be replayed to another broker topic. +type EventStreamRule struct { + RequireGoverned bool `json:"require_governed"` + RequireDisclosure bool `json:"require_disclosure,omitempty"` + RequireContentInspection bool `json:"require_content_inspection,omitempty"` + TransferQuota *TransferQuotaConfig `json:"transfer_quota,omitempty"` + ResourceTemplate string `json:"resource_template"` +} + +// TransferQuotaConfig is a per-signed-agent local admission budget. It is +// charged only for a verified governed message, event, or stream INIT; no +// transport address or caller-supplied identity participates in accounting. +type TransferQuotaConfig struct { + WindowSeconds int64 `json:"window_seconds"` + MaxBytes uint64 `json:"max_bytes,omitempty"` + MaxActions uint64 `json:"max_actions,omitempty"` + MaxSenders int `json:"max_senders"` +} + +// MandateConfig installs a protected local set of tenant-issuer-signed +// mandates. When configured, governed Decisions pass through this ceiling in +// addition to the local deterministic policy bundle. +type MandateConfig struct { + // Path is the legacy static array of individually signed mandates. It is + // suitable for local/self-hosted bootstrap, but cannot carry a monotonic + // removal revision. + Path string `json:"path,omitempty"` + // BundlePath is a protected bootstrap copy of a signed, revisioned mandate + // bundle. When Rollout is also configured, RefreshRollout replaces it only + // with a newer bundle obtained from the authority for AgentID. + BundlePath string `json:"bundle_path,omitempty"` + AgentID string `json:"agent_id,omitempty"` +} + +// ReceiptConfig gives the local enforcement daemon a tenant-delegated receipt +// signing key and an owner-only append-only journal. The agent ID is the +// signer identity, which may differ from the sender named by an incoming +// Intent; the enforcement point identifies the receiver/broker that acted. +type ReceiptConfig struct { + AgentID string `json:"agent_id"` + KeyID string `json:"key_id"` + SeedPath string `json:"seed_path"` + JournalPath string `json:"journal_path"` + ExportEndpoint string `json:"export_endpoint,omitempty"` + ExportAcknowledgementPath string `json:"export_acknowledgement_path,omitempty"` + ExportBearerTokenEnv string `json:"export_bearer_token_env,omitempty"` + ExportIntervalSeconds int64 `json:"export_interval_seconds,omitempty"` + ExportBatchSize int `json:"export_batch_size,omitempty"` +} + +// RolloutConfig opts a workload into the authority's staged policy lifecycle. +// The acknowledgement key must be the agent-scoped intent key named in the +// tenant trust bundle; its seed is read only from the protected attachment +// directory and never sent to the authority. +type RolloutConfig struct { + AuthorityEndpoint string `json:"authority_endpoint"` + AgentID string `json:"agent_id"` + AcknowledgementKeyID string `json:"acknowledgement_key_id"` + AcknowledgementSeedPath string `json:"acknowledgement_seed_path"` + PollIntervalSeconds int64 `json:"poll_interval_seconds,omitempty"` +} + +// FleetConfig enables a daemon's signed pull-based remote-operations channel. +// It reports bounded health/activity and accepts only short-lived, authority- +// signed allowlisted commands; it never opens an inbound remote shell. +type FleetConfig struct { + ReportIntervalSeconds int64 `json:"report_interval_seconds,omitempty"` + StateSyncEnabled bool `json:"state_sync_enabled,omitempty"` + StateDirectory string `json:"state_directory,omitempty"` + StateSyncIntervalSeconds int64 `json:"state_sync_interval_seconds,omitempty"` +} + +// FleetNodeStatus is intentionally bounded node telemetry suitable for an +// operator console. It excludes endpoint addresses, identities, payloads, +// prompts, local paths, and environment values. +type FleetNodeStatus struct { + NodeID uint32 + AgentVersion string + UptimeSeconds uint64 + Connections uint32 + Peers uint32 + EncryptedPeers uint32 + BytesSent uint64 + BytesReceived uint64 + PolicyRevision uint64 +} + +// OutboundDecisionConfig lets a local sender request a short-lived signed +// Decision before it transmits governed data. It is deliberately separate +// from rollout acknowledgement: an acknowledgement key proves a deployment +// reached a staged revision, whereas this key is delegated to propose actual +// business actions. The configured risk is attachment-controlled so a caller +// cannot downgrade an action to select a weaker evaluator failure policy. +type OutboundDecisionConfig struct { + AuthorityEndpoint string `json:"authority_endpoint"` + AgentID string `json:"agent_id"` + IntentKeyID string `json:"intent_key_id"` + IntentSeedPath string `json:"intent_seed_path"` + Risk decision.RiskClass `json:"risk,omitempty"` + RequestTimeoutSeconds int64 `json:"request_timeout_seconds,omitempty"` + MandateID string `json:"mandate_id,omitempty"` + Audience string `json:"audience,omitempty"` + Purpose string `json:"purpose,omitempty"` + // ContentLabels and RetentionClass are attachment-owned defaults for the + // complete request/response bodies uploaded to Pilot's hosted federation + // ingress. Application code cannot silently downgrade either field. + ContentLabels []string `json:"content_labels,omitempty"` + RetentionClass string `json:"retention_class,omitempty"` + // EvaluatorResidency is an attachment-controlled routing constraint for + // typed metadata. It does not attest an endpoint's physical geography. + EvaluatorResidency string `json:"evaluator_residency,omitempty"` + EvaluatorAttestation *EvaluatorAttestationConfig `json:"evaluator_attestation,omitempty"` +} + +// EvaluatorAttestationConfig pins a key independent from the authority's +// decision signer. Before a residency-bound disclosure request is sent, the +// runtime fetches a short-lived signed assertion and verifies this key. +type EvaluatorAttestationConfig struct { + AttestorID string `json:"attestor_id"` + KeyID string `json:"key_id"` + PublicKey string `json:"public_key"` +} + +// ContentInspectionConfig is retained only so older attachment files fail +// with a precise migration error. Pilot no longer supports a tenant-local +// semantic/content inspector; managed content travels to the Pilot-hosted +// account federation ingress before any side effect is released. +type ContentInspectionConfig struct { + PresidioEndpoint string `json:"presidio_endpoint"` + Language string `json:"language,omitempty"` + Entities []string `json:"entities,omitempty"` + ScoreThreshold float64 `json:"score_threshold,omitempty"` + MaxBytes int64 `json:"max_bytes,omitempty"` + // ProcessingResidency rejects disclosures intended for another region + // before plaintext is sent to this local inspection endpoint. + ProcessingResidency string `json:"processing_residency,omitempty"` +} + +// Runtime contains the verified local enforcement attachment. +type Runtime struct { + mu sync.Mutex + fleetStateSyncMu sync.Mutex + enforcer *decision.Enforcer + root authority.PinnedRoot + trust *authority.Store + policies *authority.PolicyManager + localPolicy *decisionpolicy.EngineInstance + actionRegistry *actionregistry.Registry + actionProfile actionregistry.Profile + actionAgentID string + actionRisk decision.RiskClass + continuations *actioncontinuation.Store + tenantID string + trustPath string + policyPath string + statePath string + mandates *replaceableMandateStore + mandateBundlePath string + mandateAgentID string + rolloutClient *authorityhttp.Client + rolloutAgentID string + rolloutKeyID string + rolloutPrivate ed25519.PrivateKey + rolloutInterval time.Duration + fleetInterval time.Duration + fleetControlPath string + fleetControl authority.FleetNodeControl + fleetControlFound bool + fleetStateEnabled bool + fleetStateInterval time.Duration + fleetStateRoot string + fleetStateCursorPath string + fleetStateRevision uint64 + fleetStateRootHash string + fleetStatePendingResults []authority.FleetStateMutationResult + outboundClient *decisionhttp.Client + outboundAgentID string + outboundKeyID string + outboundPrivate ed25519.PrivateKey + outboundRisk decision.RiskClass + outboundTimeout time.Duration + outboundMandateID string + outboundAudience string + outboundPurpose string + outboundContentLabels []string + outboundRetentionClass string + outboundEvaluatorResidency string + outboundAttestorID string + outboundAttestorKeyID string + outboundAttestorPublicKey ed25519.PublicKey + outboundAttestationExpiresAt int64 + receipts *governedReceiptSigner + receiptExporter *decision.ReceiptExporter + receiptInterval time.Duration + contentInspector decision.DisclosureContentInspector + dataResource string + eventTemplate string + dataEnabled bool + eventEnabled bool + dataRequired bool + dataDisclosureRequired bool + dataContentInspectionRequired bool + dataTransferQuota *TransferQuotaConfig + dataRetention *DataRetentionConfig + eventRequired bool + eventDisclosureRequired bool + eventContentInspectionRequired bool + eventTransferQuota *TransferQuotaConfig +} + +type governedReceiptSigner struct { + journal *decision.ReceiptJournal + trust *authority.Store + tenantID string + agentID string + keyID string + private ed25519.PrivateKey +} + +type residencyBoundInspector struct { + residency string + next decision.DisclosureContentInspector +} + +func (inspector residencyBoundInspector) InspectDisclosureContent(ctx context.Context, intent decision.Intent, disclosure *decision.DisclosureBinding, contentType, filename string, content io.Reader) error { + if disclosure == nil || disclosure.Residency != inspector.residency { + return fmt.Errorf("enterprise control: local inspector residency does not match disclosure") + } + return inspector.next.InspectDisclosureContent(ctx, intent, disclosure, contentType, filename, content) +} + +// transportReceiptRecorder binds a shared local signer to one enforcement +// point. Different governed boundaries therefore produce distinct, +// deterministic receipt IDs for the same authority decision. +type transportReceiptRecorder struct { + signer *governedReceiptSigner + enforcementPoint string +} + +func (recorder transportReceiptRecorder) RecordGovernedReceipt(ctx context.Context, intent decision.Intent, result decision.Decision) error { + return recorder.RecordGovernedResultReceipt(ctx, intent, result, decision.Enforced, time.Now()) +} + +func (recorder transportReceiptRecorder) RecordGovernedResultReceipt(ctx context.Context, intent decision.Intent, result decision.Decision, enforcementResult decision.EnforcementResult, observedAt time.Time) error { + receipt, err := recorder.newResultReceipt(intent, result, nil, enforcementResult, observedAt) + if err != nil { + return err + } + if err := receipt.Sign(recorder.signer.private); err != nil { + return fmt.Errorf("enterprise control: sign governed receipt: %w", err) + } + if err := recorder.signer.journal.AppendReceipt(ctx, receipt); err != nil { + return fmt.Errorf("enterprise control: persist governed receipt: %w", err) + } + return nil +} + +func (recorder transportReceiptRecorder) RecordGovernedDisclosureReceipt(ctx context.Context, intent decision.Intent, result decision.Decision, disclosure decision.DisclosureBinding) error { + receipt, err := recorder.newResultReceipt(intent, result, &disclosure, decision.Enforced, time.Now()) + if err != nil { + return err + } + if err := receipt.Sign(recorder.signer.private); err != nil { + return fmt.Errorf("enterprise control: sign governed disclosure receipt: %w", err) + } + if err := recorder.signer.journal.AppendReceipt(ctx, receipt); err != nil { + return fmt.Errorf("enterprise control: persist governed disclosure receipt: %w", err) + } + return nil +} + +func (recorder transportReceiptRecorder) newResultReceipt(intent decision.Intent, result decision.Decision, disclosure *decision.DisclosureBinding, enforcementResult decision.EnforcementResult, observedAt time.Time) (decision.Receipt, error) { + if recorder.signer == nil || recorder.signer.journal == nil || recorder.enforcementPoint == "" { + return decision.Receipt{}, fmt.Errorf("enterprise control: governed receipt recorder is not initialized") + } + publicKey, err := recorder.signer.trust.ReceiptKey(recorder.signer.tenantID, recorder.signer.agentID, recorder.signer.keyID) + if err != nil || !bytes.Equal(publicKey, recorder.signer.private.Public().(ed25519.PublicKey)) { + return decision.Receipt{}, fmt.Errorf("enterprise control: receipt signing key is no longer active") + } + if disclosure != nil { + receipt, err := decision.NewDisclosureReceiptForEnforcer(intent, result, *disclosure, recorder.signer.agentID, recorder.enforcementPoint, recorder.signer.keyID, observedAt.Unix(), enforcementResult) + if err != nil { + return decision.Receipt{}, fmt.Errorf("enterprise control: create governed disclosure receipt: %w", err) + } + return receipt, nil + } + receipt, err := decision.NewReceiptForEnforcer(intent, result, recorder.signer.agentID, recorder.enforcementPoint, recorder.signer.keyID, observedAt.Unix(), enforcementResult) + if err != nil { + return decision.Receipt{}, fmt.Errorf("enterprise control: create governed receipt: %w", err) + } + return receipt, nil +} + +// controlState is the restart-durable anti-rollback floor for this local +// attachment. The root pin is configured out of band; this state prevents a +// later daemon restart from accepting an older, still-valid signed bundle. +type controlState struct { + TenantID string `json:"tenant_id"` + TrustRevision uint64 `json:"trust_revision"` + TrustPolicyRevision uint64 `json:"trust_policy_revision"` + TrustRevocationEpoch uint64 `json:"trust_revocation_epoch"` + PolicyRevision uint64 `json:"policy_revision"` + PolicyRevocationEpoch uint64 `json:"policy_revocation_epoch"` + MandateRevision uint64 `json:"mandate_revision,omitempty"` + MandateRevocationEpoch uint64 `json:"mandate_revocation_epoch,omitempty"` + MandateHash string `json:"mandate_hash,omitempty"` +} + +// Load parses the strict JSON attachment at path, resolves its relative +// bundle paths, verifies every signature and state floor, and constructs an +// enforcer with no remote provider. The enforcer is used only to verify a +// sender-supplied signed Decision below the local signed policy ceiling. +func Load(path string) (*Runtime, error) { + if strings.TrimSpace(path) == "" { + return nil, fmt.Errorf("enterprise control: configuration path is required") + } + config, err := readSecureJSON[Config](path) + if err != nil { + return nil, fmt.Errorf("enterprise control: read configuration: %w", err) + } + if err := validateConfig(config); err != nil { + return nil, err + } + root, err := decodeRoot(config.RootPublicKey) + if err != nil { + return nil, err + } + directory := filepath.Dir(path) + if err := secureDirectory(directory); err != nil { + return nil, fmt.Errorf("enterprise control: configuration directory: %w", err) + } + trustPath, err := resolveBundlePath(directory, config.TrustBundlePath) + if err != nil { + return nil, fmt.Errorf("enterprise control: trust bundle: %w", err) + } + policyPath, err := resolveBundlePath(directory, config.PolicyBundlePath) + if err != nil { + return nil, fmt.Errorf("enterprise control: policy bundle: %w", err) + } + statePath := filepath.Join(directory, ".enterprise-control-state.json") + rootPin := authority.PinnedRoot{ + TenantID: config.TenantID, RootKeyID: config.RootKeyID, PublicKey: root, + } + store, err := authority.NewStore([]authority.PinnedRoot{rootPin}, time.Now) + if err != nil { + return nil, fmt.Errorf("enterprise control: initialize pinned trust: %w", err) + } + policies, err := authority.NewPolicyManager(store, decisionpolicy.Validator{}, time.Now) + if err != nil { + return nil, fmt.Errorf("enterprise control: initialize policy manager: %w", err) + } + ceiling, err := decisionpolicy.New(policies) + if err != nil { + return nil, fmt.Errorf("enterprise control: initialize policy ceiling: %w", err) + } + runtime := &Runtime{ + enforcer: &decision.Enforcer{Trust: store, Ceiling: ceiling}, root: rootPin, trust: store, policies: policies, + localPolicy: ceiling, tenantID: config.TenantID, trustPath: trustPath, policyPath: policyPath, statePath: statePath, + } + if err := runtime.Reload(); err != nil { + return nil, err + } + if config.Mandates != nil && config.Mandates.Path != "" { + mandatePath, resolveErr := resolveBundlePath(directory, config.Mandates.Path) + if resolveErr != nil { + return nil, fmt.Errorf("enterprise control: mandates: %w", resolveErr) + } + mandates, mandateErr := loadMandateStore(mandatePath, config.TenantID, store) + if mandateErr != nil { + return nil, fmt.Errorf("enterprise control: mandates: %w", mandateErr) + } + runtime.mandates = newReplaceableMandateStore(mandates) + runtime.enforcer.Ceiling = decision.MandateCeiling{Store: runtime.mandates, Keys: store, Next: ceiling} + } else if config.Mandates != nil { + bundlePath, resolveErr := resolveBundlePath(directory, config.Mandates.BundlePath) + if resolveErr != nil { + return nil, fmt.Errorf("enterprise control: mandate bundle: %w", resolveErr) + } + bundle, mandates, mandateErr := loadMandateBundleStore(bundlePath, config.TenantID, config.Mandates.AgentID, store) + if mandateErr != nil { + return nil, fmt.Errorf("enterprise control: mandate bundle: %w", mandateErr) + } + runtime.mandates = newReplaceableMandateStore(mandates) + runtime.mandateBundlePath = bundlePath + runtime.mandateAgentID = config.Mandates.AgentID + if mandateErr := runtime.installMandateBundleLocked(bundle, mandates); mandateErr != nil { + return nil, mandateErr + } + runtime.enforcer.Ceiling = decision.MandateCeiling{Store: runtime.mandates, Keys: store, Next: ceiling} + } + if config.Receipts != nil { + seedPath, resolveErr := resolveBundlePath(directory, config.Receipts.SeedPath) + if resolveErr != nil { + return nil, fmt.Errorf("enterprise control: receipt signing seed: %w", resolveErr) + } + journalPath, resolveErr := resolveBundlePath(directory, config.Receipts.JournalPath) + if resolveErr != nil { + return nil, fmt.Errorf("enterprise control: receipt journal: %w", resolveErr) + } + privateKey, seedErr := readEd25519Seed(seedPath) + if seedErr != nil { + return nil, fmt.Errorf("enterprise control: receipt signing seed: %w", seedErr) + } + publicKey, keyErr := runtime.trust.ReceiptKey(config.TenantID, config.Receipts.AgentID, config.Receipts.KeyID) + if keyErr != nil || !bytes.Equal(publicKey, privateKey.Public().(ed25519.PublicKey)) { + return nil, fmt.Errorf("enterprise control: receipt signing seed does not match active agent receipt key") + } + journal, journalErr := decision.OpenReceiptJournal(journalPath) + if journalErr != nil { + return nil, fmt.Errorf("enterprise control: receipt journal: %w", journalErr) + } + runtime.receipts = &governedReceiptSigner{ + journal: journal, trust: runtime.trust, tenantID: config.TenantID, + agentID: config.Receipts.AgentID, keyID: config.Receipts.KeyID, private: privateKey, + } + if config.Receipts.ExportEndpoint != "" { + ackPath, ackResolveErr := resolveBundlePath(directory, config.Receipts.ExportAcknowledgementPath) + if ackResolveErr != nil { + return nil, fmt.Errorf("enterprise control: receipt export acknowledgement path: %w", ackResolveErr) + } + exporter, exportErr := decision.NewReceiptExporter(decision.ReceiptExporterConfig{ + Journal: journal, Endpoint: config.Receipts.ExportEndpoint, AckPath: ackPath, + BearerToken: os.Getenv(config.Receipts.ExportBearerTokenEnv), + Interval: time.Duration(config.Receipts.ExportIntervalSeconds) * time.Second, + BatchSize: config.Receipts.ExportBatchSize, + }) + if exportErr != nil { + return nil, fmt.Errorf("enterprise control: receipt exporter: %w", exportErr) + } + runtime.receiptExporter = exporter + runtime.receiptInterval = time.Duration(config.Receipts.ExportIntervalSeconds) * time.Second + if runtime.receiptInterval == 0 { + runtime.receiptInterval = 30 * time.Second + } + } + } + if config.OutboundDecisions != nil { + seedPath, resolveErr := resolveBundlePath(directory, config.OutboundDecisions.IntentSeedPath) + if resolveErr != nil { + return nil, fmt.Errorf("enterprise control: outbound decision seed: %w", resolveErr) + } + privateKey, seedErr := readEd25519Seed(seedPath) + if seedErr != nil { + return nil, fmt.Errorf("enterprise control: outbound decision seed: %w", seedErr) + } + client, clientErr := decisionhttp.New(config.OutboundDecisions.AuthorityEndpoint) + if clientErr != nil { + return nil, fmt.Errorf("enterprise control: outbound decision authority endpoint: %w", clientErr) + } + publicKey, keyErr := runtime.trust.IntentKey(context.Background(), config.TenantID, config.OutboundDecisions.AgentID, config.OutboundDecisions.IntentKeyID) + if keyErr != nil || !bytes.Equal(publicKey, privateKey.Public().(ed25519.PublicKey)) { + return nil, fmt.Errorf("enterprise control: outbound decision seed does not match active agent intent key") + } + runtime.outboundClient = client + runtime.outboundAgentID = config.OutboundDecisions.AgentID + runtime.outboundKeyID = config.OutboundDecisions.IntentKeyID + runtime.outboundPrivate = privateKey + runtime.outboundRisk = config.OutboundDecisions.Risk + if runtime.outboundRisk == "" { + runtime.outboundRisk = decision.RiskHigh + } + runtime.outboundTimeout = time.Duration(config.OutboundDecisions.RequestTimeoutSeconds) * time.Second + if runtime.outboundTimeout == 0 { + runtime.outboundTimeout = 10 * time.Second + } + runtime.outboundMandateID = config.OutboundDecisions.MandateID + runtime.outboundAudience = config.OutboundDecisions.Audience + runtime.outboundPurpose = config.OutboundDecisions.Purpose + runtime.outboundContentLabels = append([]string(nil), config.OutboundDecisions.ContentLabels...) + runtime.outboundRetentionClass = config.OutboundDecisions.RetentionClass + runtime.outboundEvaluatorResidency = config.OutboundDecisions.EvaluatorResidency + if config.OutboundDecisions.EvaluatorAttestation != nil { + attestor := config.OutboundDecisions.EvaluatorAttestation + publicKey, keyErr := decodeEvaluatorAttestorKey(attestor.PublicKey) + if keyErr != nil { + return nil, keyErr + } + runtime.outboundAttestorID = attestor.AttestorID + runtime.outboundAttestorKeyID = attestor.KeyID + runtime.outboundAttestorPublicKey = publicKey + } + } + if config.Fleet != nil { + runtime.fleetInterval = time.Duration(config.Fleet.ReportIntervalSeconds) * time.Second + if runtime.fleetInterval == 0 { + runtime.fleetInterval = 30 * time.Second + } + runtime.fleetControlPath = filepath.Join(directory, ".enterprise-fleet-control.json") + if config.Fleet.StateSyncEnabled { + stateDirectory := strings.TrimSpace(config.Fleet.StateDirectory) + if stateDirectory == "" { + stateDirectory = "." + } + stateRoot, resolveErr := resolveBundlePath(directory, stateDirectory) + if resolveErr != nil { + return nil, fmt.Errorf("enterprise control: fleet state directory: %w", resolveErr) + } + if err := secureDirectory(stateRoot); err != nil { + return nil, fmt.Errorf("enterprise control: fleet state directory: %w", err) + } + runtime.fleetStateEnabled = true + runtime.fleetStateRoot = stateRoot + runtime.fleetStateCursorPath = filepath.Join(stateRoot, ".enterprise-fleet-state-cursor.json") + runtime.fleetStateInterval = time.Duration(config.Fleet.StateSyncIntervalSeconds) * time.Second + if runtime.fleetStateInterval == 0 { + runtime.fleetStateInterval = 5 * time.Second + } + } + } + if config.DataExchange != nil { + runtime.dataEnabled = true + runtime.dataRequired = config.DataExchange.RequireGoverned + runtime.dataDisclosureRequired = config.DataExchange.RequireDisclosure + runtime.dataTransferQuota = config.DataExchange.TransferQuota + runtime.dataRetention = config.DataExchange.Retention + runtime.dataResource = config.DataExchange.Resource + } + if config.EventStream != nil { + runtime.eventEnabled = true + runtime.eventRequired = config.EventStream.RequireGoverned + runtime.eventDisclosureRequired = config.EventStream.RequireDisclosure + runtime.eventTransferQuota = config.EventStream.TransferQuota + runtime.eventTemplate = config.EventStream.ResourceTemplate + } + if config.Rollout != nil { + seedPath, resolveErr := resolveBundlePath(directory, config.Rollout.AcknowledgementSeedPath) + if resolveErr != nil { + return nil, fmt.Errorf("enterprise control: rollout acknowledgement seed: %w", resolveErr) + } + privateKey, seedErr := readEd25519Seed(seedPath) + if seedErr != nil { + return nil, fmt.Errorf("enterprise control: rollout acknowledgement seed: %w", seedErr) + } + client, clientErr := authorityhttp.New(config.Rollout.AuthorityEndpoint, nil) + if clientErr != nil { + return nil, fmt.Errorf("enterprise control: rollout authority endpoint: %w", clientErr) + } + publicKey, keyErr := runtime.trust.IntentKey(context.Background(), config.TenantID, config.Rollout.AgentID, config.Rollout.AcknowledgementKeyID) + if keyErr != nil || !bytes.Equal(publicKey, privateKey.Public().(ed25519.PublicKey)) { + return nil, fmt.Errorf("enterprise control: rollout acknowledgement seed does not match active agent intent key") + } + runtime.rolloutClient = client + runtime.rolloutAgentID = config.Rollout.AgentID + runtime.rolloutKeyID = config.Rollout.AcknowledgementKeyID + runtime.rolloutPrivate = privateKey + runtime.rolloutInterval = time.Duration(config.Rollout.PollIntervalSeconds) * time.Second + if runtime.rolloutInterval == 0 { + runtime.rolloutInterval = 30 * time.Second + } + } + if config.ActionControl != nil { + runtime.actionRegistry = actionregistry.Builtins() + runtime.actionProfile = config.ActionControl.Profile + runtime.actionAgentID = config.ActionControl.AgentID + if runtime.actionAgentID == "" && config.OutboundDecisions != nil { + runtime.actionAgentID = config.OutboundDecisions.AgentID + } + runtime.actionRisk = config.ActionControl.Risk + if runtime.actionRisk == "" { + runtime.actionRisk = decision.RiskHigh + } + if runtime.actionProfile.Mode.Normalize() == actionregistry.ModeManagedEnforce { + continuationDirectory := strings.TrimSpace(config.ActionControl.ContinuationDirectory) + if continuationDirectory == "" { + continuationDirectory = "continuations" + } + continuationPath, resolveErr := resolveBundlePath(directory, continuationDirectory) + if resolveErr != nil { + return nil, fmt.Errorf("enterprise control: action continuation directory: %w", resolveErr) + } + continuations, continuationErr := actioncontinuation.Open(continuationPath) + if continuationErr != nil { + return nil, fmt.Errorf("enterprise control: action continuation directory: %w", continuationErr) + } + runtime.continuations = continuations + } + } + if config.Fleet != nil { + if err := runtime.loadPersistedFleetControl(); err != nil { + return nil, err + } + if err := runtime.loadFleetStateCursor(); err != nil { + return nil, err + } + } + return runtime, nil +} + +// Reload atomically validates a newer pair of local signed bundles against a +// temporary authority state, then installs it below the already-attached +// enforcer. Resource mappings and the root pin are immutable until restart; +// SIGHUP therefore advances only signed authority state, not configuration. +func (runtime *Runtime) Reload() error { + if runtime == nil || runtime.trust == nil || runtime.policies == nil || runtime.enforcer == nil { + return fmt.Errorf("enterprise control: runtime is not initialized") + } + runtime.mu.Lock() + defer runtime.mu.Unlock() + return runtime.reloadLocked() +} + +func (runtime *Runtime) reloadLocked() error { + trustBundle, err := readSecureJSON[authority.TrustBundle](runtime.trustPath) + if err != nil { + return fmt.Errorf("enterprise control: read trust bundle: %w", err) + } + policyBundle, err := readSecureJSON[authority.PolicyBundle](runtime.policyPath) + if err != nil { + return fmt.Errorf("enterprise control: read policy bundle: %w", err) + } + if trustBundle.TenantID != runtime.tenantID || policyBundle.TenantID != runtime.tenantID { + return fmt.Errorf("enterprise control: trust and policy bundles must match configured tenant") + } + state, err := loadControlState(runtime.statePath) + if err != nil { + return err + } + if err := state.accepts(runtime.tenantID, trustBundle, policyBundle); err != nil { + return err + } + // Validate both objects against a new store first. This ensures malformed + // or mismatched input cannot partially mutate the active enforcement state. + probeTrust, err := authority.NewStore([]authority.PinnedRoot{runtime.root}, time.Now) + if err != nil { + return fmt.Errorf("enterprise control: initialize reload trust: %w", err) + } + if err := probeTrust.Install(trustBundle); err != nil { + return fmt.Errorf("enterprise control: verify trust bundle: %w", err) + } + probePolicies, err := authority.NewPolicyManager(probeTrust, decisionpolicy.Validator{}, time.Now) + if err != nil { + return fmt.Errorf("enterprise control: initialize reload policy: %w", err) + } + // A trust rotation can intentionally raise the policy or revocation floor + // before the corresponding active policy is available locally. Keep the + // root-verified trust state and start fail-closed in that case; the rollout + // loop can subsequently fetch the signed, authority-activated policy. Any + // other malformed or unauthenticated policy remains a startup error. + policyUnavailable := policyBundle.Revision < trustBundle.PolicyRevision || policyBundle.RevocationEpoch < trustBundle.RevocationEpoch + if policyUnavailable { + if err := policyBundle.Validate(); err != nil { + return fmt.Errorf("enterprise control: verify stale policy bundle: %w", err) + } + } else if err := probePolicies.Install(context.Background(), policyBundle); err != nil { + return fmt.Errorf("enterprise control: verify policy bundle: %w", err) + } + // Persist the anti-rollback floor before exposing the newer state. A disk + // failure therefore leaves the old live enforcer untouched rather than + // making a successful-looking reload disappear after the next restart. + nextState := state + nextState.TenantID = runtime.tenantID + nextState.TrustRevision = trustBundle.Revision + nextState.TrustPolicyRevision = trustBundle.PolicyRevision + nextState.TrustRevocationEpoch = trustBundle.RevocationEpoch + nextState.PolicyRevision = policyBundle.Revision + nextState.PolicyRevocationEpoch = policyBundle.RevocationEpoch + if err := saveControlState(runtime.statePath, nextState); err != nil { + return err + } + if err := runtime.trust.Install(trustBundle); err != nil { + return fmt.Errorf("enterprise control: install trust bundle: %w", err) + } + if policyUnavailable { + return nil + } + if err := runtime.policies.Install(context.Background(), policyBundle); err != nil { + return fmt.Errorf("enterprise control: install policy bundle: %w", err) + } + return nil +} + +// HasRollout reports whether this attachment is configured to participate in +// the authority's staged policy acknowledgement lifecycle. +func (runtime *Runtime) HasRollout() bool { + return runtime != nil && runtime.rolloutClient != nil && runtime.rolloutAgentID != "" && runtime.rolloutKeyID != "" && len(runtime.rolloutPrivate) == ed25519.PrivateKeySize +} + +// HasFleetControl reports whether the daemon participates in the signed +// pull-based fleet operations channel. +func (runtime *Runtime) HasFleetControl() bool { + return runtime != nil && runtime.fleetInterval > 0 && runtime.HasRollout() +} + +func (runtime *Runtime) FleetReportInterval() time.Duration { + if !runtime.HasFleetControl() { + return 0 + } + return runtime.fleetInterval +} + +// FleetReconciliation is the bounded local result of applying one signed +// desired-state revision. Runtime and policy changes remain explicit: Pilot +// can refresh an already signed policy, while a binary-version drift requires +// the deployment mechanism to restart the node. +type FleetReconciliation struct { + Control authority.FleetNodeControl + Found bool + Status string + DetailCode string + AppliedPolicyRevision uint64 + VersionMatches bool +} + +// ReconcileFleetControl fetches, verifies, anti-rolls back, and durably +// installs the authority's desired state. A missing remote object retains the +// last signed local state; clearing desired state therefore requires a newer +// signed revision instead of an unauthenticated absence. +func (runtime *Runtime) ReconcileFleetControl(ctx context.Context, runningVersion string) (FleetReconciliation, error) { + if !runtime.HasFleetControl() { + return FleetReconciliation{}, fmt.Errorf("enterprise control: fleet control is not configured") + } + runtime.mu.Lock() + client, tenantID, agentID := runtime.rolloutClient, runtime.tenantID, runtime.rolloutAgentID + runtime.mu.Unlock() + remote, found, err := client.FleetControl(ctx, tenantID, agentID) + if err != nil { + return FleetReconciliation{}, fmt.Errorf("enterprise control: fetch desired fleet state: %w", err) + } + if found { + publicKey, keyErr := runtime.trust.DecisionKey(ctx, tenantID, remote.KeyID) + if keyErr != nil || remote.Verify(publicKey, time.Now()) != nil { + return FleetReconciliation{}, fmt.Errorf("enterprise control: invalid desired fleet state") + } + runtime.mu.Lock() + err = runtime.installFleetControlLocked(remote) + runtime.mu.Unlock() + if err != nil { + return FleetReconciliation{}, err + } + } + runtime.mu.Lock() + control, installed := runtime.fleetControl, runtime.fleetControlFound + runtime.mu.Unlock() + if !installed { + return FleetReconciliation{Status: "no_desired_state", VersionMatches: true}, nil + } + policy, err := runtime.policies.Active(ctx, tenantID) + if err != nil { + return FleetReconciliation{}, fmt.Errorf("enterprise control: read active policy for reconciliation: %w", err) + } + if control.DesiredPolicyRevision > policy.Revision { + if refreshErr := runtime.RefreshRollout(ctx); refreshErr != nil { + return FleetReconciliation{Control: control, Found: true, Status: "partially_applied", DetailCode: "policy_refresh_failed", AppliedPolicyRevision: policy.Revision, VersionMatches: control.DesiredVersion == "" || control.DesiredVersion == runningVersion}, refreshErr + } + policy, err = runtime.policies.Active(ctx, tenantID) + if err != nil { + return FleetReconciliation{}, err + } + } + result := FleetReconciliation{Control: control, Found: true, Status: "applied", AppliedPolicyRevision: policy.Revision, VersionMatches: control.DesiredVersion == "" || control.DesiredVersion == runningVersion} + if control.DesiredPolicyRevision != 0 && policy.Revision != control.DesiredPolicyRevision { + result.Status, result.DetailCode = "partially_applied", "policy_revision_mismatch" + } + if !result.VersionMatches { + result.Status, result.DetailCode = "partially_applied", "version_restart_required" + } + return result, nil +} + +func (runtime *Runtime) CurrentPolicyRevision(ctx context.Context) uint64 { + if runtime == nil || runtime.policies == nil { + return 0 + } + policy, err := runtime.policies.Active(ctx, runtime.tenantID) + if err != nil { + return 0 + } + return policy.Revision +} + +func (runtime *Runtime) CurrentFleetControl() (authority.FleetNodeControl, bool) { + if runtime == nil { + return authority.FleetNodeControl{}, false + } + runtime.mu.Lock() + defer runtime.mu.Unlock() + return runtime.fleetControl, runtime.fleetControlFound +} + +func (runtime *Runtime) loadPersistedFleetControl() error { + if runtime.fleetControlPath == "" { + return nil + } + if _, err := os.Lstat(runtime.fleetControlPath); os.IsNotExist(err) { + return nil + } else if err != nil { + return fmt.Errorf("enterprise control: inspect persisted fleet control: %w", err) + } + control, err := readSecureJSON[authority.FleetNodeControl](runtime.fleetControlPath) + if err != nil { + return fmt.Errorf("enterprise control: read persisted fleet control: %w", err) + } + publicKey, err := runtime.trust.DecisionKey(context.Background(), runtime.tenantID, control.KeyID) + if err != nil || control.Verify(publicKey, time.Now()) != nil || control.AgentID != runtime.rolloutAgentID { + return fmt.Errorf("enterprise control: invalid persisted fleet control") + } + runtime.fleetControl, runtime.fleetControlFound = control, true + return nil +} + +func (runtime *Runtime) installFleetControlLocked(control authority.FleetNodeControl) error { + if control.TenantID != runtime.tenantID || control.AgentID != runtime.rolloutAgentID { + return fmt.Errorf("enterprise control: desired fleet state binding mismatch") + } + if runtime.fleetControlFound { + if control.Revision < runtime.fleetControl.Revision { + return fmt.Errorf("enterprise control: desired fleet state is below local rollback floor") + } + if control.Revision == runtime.fleetControl.Revision { + if control.Signature != runtime.fleetControl.Signature { + return fmt.Errorf("enterprise control: conflicting desired fleet state revision") + } + return nil + } + } + if err := writeSecureJSON(runtime.fleetControlPath, control); err != nil { + return fmt.Errorf("enterprise control: persist desired fleet state: %w", err) + } + runtime.fleetControl, runtime.fleetControlFound = control, true + return nil +} + +// ReportFleetStatus signs and publishes bounded operational telemetry using +// the enrolled rollout intent key. A reporting failure does not alter local +// authorization or governed delivery. +func (runtime *Runtime) ReportFleetStatus(ctx context.Context, status FleetNodeStatus) error { + if !runtime.HasFleetControl() { + return fmt.Errorf("enterprise control: fleet control is not configured") + } + runtime.mu.Lock() + client := runtime.rolloutClient + tenantID, agentID, keyID := runtime.tenantID, runtime.rolloutAgentID, runtime.rolloutKeyID + privateKey := append(ed25519.PrivateKey(nil), runtime.rolloutPrivate...) + runtime.mu.Unlock() + now := time.Now().UTC() + report := authority.FleetNodeReport{ + Version: authority.FleetReportVersion, TenantID: tenantID, AgentID: agentID, NodeID: status.NodeID, + AgentVersion: status.AgentVersion, ObservedAt: now.Unix(), UptimeSeconds: status.UptimeSeconds, + Connections: status.Connections, Peers: status.Peers, EncryptedPeers: status.EncryptedPeers, + BytesSent: status.BytesSent, BytesReceived: status.BytesReceived, PolicyRevision: status.PolicyRevision, KeyID: keyID, + } + if err := report.Sign(privateKey); err != nil { + return err + } + if err := client.ReportFleetNode(ctx, report); err != nil { + return fmt.Errorf("enterprise control: report fleet status: %w", err) + } + return nil +} + +// FleetCommands returns only commands which verify against the daemon's +// locally pinned authority trust and target this enrolled agent. +func (runtime *Runtime) FleetCommands(ctx context.Context) ([]authority.FleetCommand, error) { + if !runtime.HasFleetControl() { + return nil, fmt.Errorf("enterprise control: fleet control is not configured") + } + runtime.mu.Lock() + client := runtime.rolloutClient + tenantID, agentID := runtime.tenantID, runtime.rolloutAgentID + runtime.mu.Unlock() + commands, err := client.FleetCommands(ctx, tenantID, agentID) + if err != nil { + return nil, fmt.Errorf("enterprise control: fetch fleet commands: %w", err) + } + verified := make([]authority.FleetCommand, 0, len(commands)) + for _, command := range commands { + publicKey, keyErr := runtime.trust.DecisionKey(ctx, tenantID, command.KeyID) + if keyErr != nil || command.Verify(publicKey, time.Now()) != nil || !command.TargetsAgent(agentID) { + return nil, fmt.Errorf("enterprise control: invalid fleet command") + } + verified = append(verified, command) + } + return verified, nil +} + +// ReportFleetCommandResult signs an allowlisted-command outcome. The remote +// service keeps the short result code, never daemon logs or raw errors. +func (runtime *Runtime) ReportFleetCommandResult(ctx context.Context, commandID, outcome, detailCode string) error { + if !runtime.HasFleetControl() { + return fmt.Errorf("enterprise control: fleet control is not configured") + } + runtime.mu.Lock() + client := runtime.rolloutClient + tenantID, agentID, keyID := runtime.tenantID, runtime.rolloutAgentID, runtime.rolloutKeyID + privateKey := append(ed25519.PrivateKey(nil), runtime.rolloutPrivate...) + runtime.mu.Unlock() + result := authority.FleetCommandResult{ + Version: authority.FleetReportVersion, TenantID: tenantID, AgentID: agentID, CommandID: commandID, + Outcome: outcome, DetailCode: detailCode, ObservedAt: time.Now().UTC().Unix(), KeyID: keyID, + } + if err := result.Sign(privateKey); err != nil { + return err + } + if err := client.ReportFleetResult(ctx, result); err != nil { + return fmt.Errorf("enterprise control: report fleet command result: %w", err) + } + return nil +} + +// ReportFleetControlAcknowledgement gives the management plane signed proof +// of the exact desired-state revision the node reached. The record contains +// only coarse applied state and never local paths, endpoints, or payloads. +func (runtime *Runtime) ReportFleetControlAcknowledgement(ctx context.Context, reconciliation FleetReconciliation, runningVersion string) error { + if !runtime.HasFleetControl() || !reconciliation.Found { + return fmt.Errorf("enterprise control: no fleet desired state to acknowledge") + } + runtime.mu.Lock() + client := runtime.rolloutClient + tenantID, agentID, keyID := runtime.tenantID, runtime.rolloutAgentID, runtime.rolloutKeyID + privateKey := append(ed25519.PrivateKey(nil), runtime.rolloutPrivate...) + runtime.mu.Unlock() + status := reconciliation.Status + switch status { + case authority.FleetControlApplied, authority.FleetControlPartiallyApplied, authority.FleetControlRejected: + default: + status = authority.FleetControlRejected + if reconciliation.DetailCode == "" { + reconciliation.DetailCode = "reconciliation_failed" + } + } + acknowledgement := authority.FleetControlAcknowledgement{ + Version: authority.FleetControlAckVersion, TenantID: tenantID, AgentID: agentID, + ControlRevision: reconciliation.Control.Revision, Status: status, DetailCode: reconciliation.DetailCode, + AppliedPolicyRevision: reconciliation.AppliedPolicyRevision, RunningVersion: runningVersion, + Quarantined: reconciliation.Control.Quarantined, ObservedAt: time.Now().UTC().Unix(), KeyID: keyID, + } + if err := acknowledgement.Sign(privateKey); err != nil { + return err + } + if err := client.ReportFleetControlAcknowledgement(ctx, acknowledgement); err != nil { + return fmt.Errorf("enterprise control: report fleet control acknowledgement: %w", err) + } + return nil +} + +// HasOutboundDecisions reports whether this attachment can request signed +// Decisions for a local sender. It is opt-in so open-agent installations keep +// their existing direct message and file behavior. +func (runtime *Runtime) HasOutboundDecisions() bool { + return runtime != nil && runtime.outboundClient != nil && runtime.outboundAgentID != "" && runtime.outboundKeyID != "" && len(runtime.outboundPrivate) == ed25519.PrivateKeySize +} + +// AuthorizeOutbound creates a fresh, signed Intent and asks the configured +// authority for a Decision. It verifies the response against the locally +// pinned trust and deterministic policy ceiling before returning it. A deny or +// approval-required result is returned as a valid Decision; the caller must +// not perform the side effect unless the outcome permits it. +func (runtime *Runtime) AuthorizeOutbound(ctx context.Context, action, resource, payloadHash string) (decision.Intent, decision.Decision, error) { + return runtime.authorizeOutbound(ctx, action, resource, payloadHash, nil) +} + +// AuthorizeOutboundDisclosure creates an outbound Intent whose payload hash +// binds typed disclosure metadata, then sends that metadata through the +// disclosure-aware authority envelope. The attachment-owned audience and +// purpose must match the binding so an application cannot select a different +// recipient or stated purpose at request time. +func (runtime *Runtime) AuthorizeOutboundDisclosure(ctx context.Context, action, resource string, disclosure decision.DisclosureBinding) (decision.Intent, decision.Decision, error) { + payloadHash, err := disclosure.Hash() + if err != nil { + return decision.Intent{}, decision.Decision{}, err + } + return runtime.authorizeOutbound(ctx, action, resource, payloadHash, &disclosure) +} + +// AuthorizeOutboundFederatedContent sends the exact exchange body to the +// Pilot-hosted account ingress configured as AuthorityEndpoint. The hosted +// response remains below the locally pinned policy ceiling and is bound to the +// disclosure hash; a customer-local semantic inspector is never invoked. +func (runtime *Runtime) AuthorizeOutboundFederatedContent(ctx context.Context, action, resource string, content decision.FederatedContent) (decision.Intent, decisionhttp.FederationExchangeResponse, error) { + if !runtime.HasOutboundDecisions() { + return decision.Intent{}, decisionhttp.FederationExchangeResponse{}, fmt.Errorf("enterprise control: outbound decisions are not configured") + } + if err := content.Validate(); err != nil { + return decision.Intent{}, decisionhttp.FederationExchangeResponse{}, err + } + runtime.mu.Lock() + client := runtime.outboundClient + agentID, keyID := runtime.outboundAgentID, runtime.outboundKeyID + privateKey := append(ed25519.PrivateKey(nil), runtime.outboundPrivate...) + risk, timeout := runtime.outboundRisk, runtime.outboundTimeout + mandateID, audience, purpose := runtime.outboundMandateID, runtime.outboundAudience, runtime.outboundPurpose + evaluatorResidency := runtime.outboundEvaluatorResidency + attestorConfigured := len(runtime.outboundAttestorPublicKey) == ed25519.PublicKeySize + runtime.mu.Unlock() + disclosure := content.Disclosure + if audience != disclosure.Recipient || purpose != disclosure.Purpose { + return decision.Intent{}, decisionhttp.FederationExchangeResponse{}, fmt.Errorf("enterprise control: federated content recipient and purpose must match attachment") + } + if evaluatorResidency != "" && disclosure.Residency != evaluatorResidency { + return decision.Intent{}, decisionhttp.FederationExchangeResponse{}, fmt.Errorf("enterprise control: federated content residency does not match hosted evaluator routing") + } + if attestorConfigured { + attestationContext, cancel := context.WithTimeout(ctx, timeout) + err := runtime.verifyEvaluatorAttestation(attestationContext, client, evaluatorResidency) + cancel() + if err != nil { + return decision.Intent{}, decisionhttp.FederationExchangeResponse{}, err + } + } + publicKey, keyErr := runtime.trust.IntentKey(ctx, runtime.tenantID, agentID, keyID) + if keyErr != nil || !bytes.Equal(publicKey, privateKey.Public().(ed25519.PublicKey)) { + return decision.Intent{}, decisionhttp.FederationExchangeResponse{}, fmt.Errorf("enterprise control: outbound intent key is no longer active") + } + payloadHash, err := disclosure.Hash() + if err != nil { + return decision.Intent{}, decisionhttp.FederationExchangeResponse{}, err + } + nonce, err := decision.NewNonce() + if err != nil { + return decision.Intent{}, decisionhttp.FederationExchangeResponse{}, err + } + now := time.Now().UTC() + intent := decision.Intent{ + Version: decision.SchemaVersion, ID: "federation-" + nonce, + TenantID: runtime.tenantID, AgentID: agentID, Action: action, Resource: resource, + MandateID: mandateID, Audience: audience, Purpose: purpose, PayloadHash: payloadHash, Risk: risk, + IssuedAt: now.Unix(), ExpiresAt: now.Add(2 * time.Minute).Unix(), Nonce: nonce, KeyID: keyID, + } + if err := intent.Sign(privateKey); err != nil { + return decision.Intent{}, decisionhttp.FederationExchangeResponse{}, err + } + requestContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + response, err := client.SubmitFederationExchange(requestContext, decisionhttp.FederationExchangeRequest{ + Version: decisionhttp.FederationExchangeVersion, Intent: intent, Content: content.Clone(), + }) + if err != nil { + return decision.Intent{}, decisionhttp.FederationExchangeResponse{}, fmt.Errorf("enterprise control: hosted federation exchange: %w", err) + } + if err := runtime.enforcer.VerifyDisclosure(ctx, intent, response.Decision, disclosure); err != nil { + return decision.Intent{}, decisionhttp.FederationExchangeResponse{}, fmt.Errorf("enterprise control: verify hosted federation decision: %w", err) + } + return intent, response, nil +} + +// NewOutboundFederatedContent creates the exact hosted request attachment +// from immutable node configuration and the adapter's real bytes. It is the +// only supported construction path for built-in managed adapters, preventing +// application-selected labels, purpose, recipient, retention, or residency. +func (runtime *Runtime) NewOutboundFederatedContent(contentType, filename string, body []byte) (decision.FederatedContent, error) { + if runtime == nil || !runtime.HasOutboundDecisions() { + return decision.FederatedContent{}, fmt.Errorf("enterprise control: outbound decisions are not configured") + } + runtime.mu.Lock() + labels := append([]string(nil), runtime.outboundContentLabels...) + recipient, purpose := runtime.outboundAudience, runtime.outboundPurpose + residency, retention := runtime.outboundEvaluatorResidency, runtime.outboundRetentionClass + runtime.mu.Unlock() + if len(labels) == 0 { + labels = []string{"unclassified"} + } + if retention == "" { + retention = "exchange-7d" + } + disclosure := decision.DisclosureBinding{ + Version: decision.DisclosureBindingRetentionVersion, ContentHash: decision.HashPayload(body), + DeclaredBytes: uint64(len(body)), ContentType: strings.ToLower(strings.TrimSpace(contentType)), + Labels: labels, Recipient: recipient, Purpose: purpose, Residency: residency, + Filename: filename, RetentionClass: retention, + } + return decision.NewFederatedContent(disclosure, body) +} + +// NewOutboundFederatedResponseContent creates the post-hook counterpart for +// bytes returned to this node. Response metadata is still node-owned and +// cannot be supplied by the remote peer or application harness. +func (runtime *Runtime) NewOutboundFederatedResponseContent(contentType, filename string, body []byte) (decision.FederatedContent, error) { + if runtime == nil || !runtime.HasOutboundDecisions() { + return decision.FederatedContent{}, fmt.Errorf("enterprise control: outbound decisions are not configured") + } + runtime.mu.Lock() + labels := append([]string(nil), runtime.outboundContentLabels...) + agentID, purpose := runtime.outboundAgentID, runtime.outboundPurpose + residency, retention := runtime.outboundEvaluatorResidency, runtime.outboundRetentionClass + runtime.mu.Unlock() + if len(labels) == 0 { + labels = []string{"unclassified"} + } + if retention == "" { + retention = "exchange-7d" + } + if len(purpose) > 247 { + purpose = purpose[:247] + } + disclosure := decision.DisclosureBinding{ + Version: decision.DisclosureBindingRetentionVersion, ContentHash: decision.HashPayload(body), + DeclaredBytes: uint64(len(body)), ContentType: strings.ToLower(strings.TrimSpace(contentType)), + Labels: labels, Recipient: "agent:" + agentID, Purpose: purpose + ".response", + Residency: residency, Filename: filename, RetentionClass: retention, + } + return decision.NewFederatedContent(disclosure, body) +} + +func (runtime *Runtime) authorizeOutbound(ctx context.Context, action, resource, payloadHash string, disclosure *decision.DisclosureBinding) (decision.Intent, decision.Decision, error) { + if !runtime.HasOutboundDecisions() { + return decision.Intent{}, decision.Decision{}, fmt.Errorf("enterprise control: outbound decisions are not configured") + } + nonce, err := decision.NewNonce() + if err != nil { + return decision.Intent{}, decision.Decision{}, err + } + runtime.mu.Lock() + client := runtime.outboundClient + agentID := runtime.outboundAgentID + keyID := runtime.outboundKeyID + privateKey := append(ed25519.PrivateKey(nil), runtime.outboundPrivate...) + risk := runtime.outboundRisk + timeout := runtime.outboundTimeout + mandateID := runtime.outboundMandateID + audience := runtime.outboundAudience + purpose := runtime.outboundPurpose + evaluatorResidency := runtime.outboundEvaluatorResidency + attestorConfigured := len(runtime.outboundAttestorPublicKey) == ed25519.PublicKeySize + runtime.mu.Unlock() + if disclosure != nil && (audience != disclosure.Recipient || purpose != disclosure.Purpose) { + return decision.Intent{}, decision.Decision{}, fmt.Errorf("enterprise control: outbound disclosure recipient and purpose must match attachment") + } + if disclosure != nil && evaluatorResidency != "" && disclosure.Residency != evaluatorResidency { + return decision.Intent{}, decision.Decision{}, fmt.Errorf("enterprise control: outbound disclosure residency does not match configured evaluator") + } + if disclosure != nil && attestorConfigured { + attestationContext, cancel := context.WithTimeout(ctx, timeout) + err := runtime.verifyEvaluatorAttestation(attestationContext, client, evaluatorResidency) + cancel() + if err != nil { + return decision.Intent{}, decision.Decision{}, err + } + } + if publicKey, keyErr := runtime.trust.IntentKey(ctx, runtime.tenantID, agentID, keyID); keyErr != nil || !bytes.Equal(publicKey, privateKey.Public().(ed25519.PublicKey)) { + return decision.Intent{}, decision.Decision{}, fmt.Errorf("enterprise control: outbound intent key is no longer active") + } + now := time.Now().UTC() + intent := decision.Intent{ + Version: decision.SchemaVersion, ID: "outbound-" + nonce, + TenantID: runtime.tenantID, AgentID: agentID, Action: action, Resource: resource, + MandateID: mandateID, Audience: audience, Purpose: purpose, + PayloadHash: payloadHash, Risk: risk, + IssuedAt: now.Unix(), ExpiresAt: now.Add(2 * time.Minute).Unix(), Nonce: nonce, KeyID: keyID, + } + if err := intent.Sign(privateKey); err != nil { + return decision.Intent{}, decision.Decision{}, err + } + requestContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + var result decision.Decision + if disclosure != nil { + result, err = client.AuthorizeDisclosure(requestContext, intent, *disclosure) + } else { + result, err = client.Authorize(requestContext, intent) + } + if err != nil { + return decision.Intent{}, decision.Decision{}, fmt.Errorf("enterprise control: request outbound decision: %w", err) + } + if err := runtime.enforcer.Verify(ctx, intent, result); err != nil { + return decision.Intent{}, decision.Decision{}, fmt.Errorf("enterprise control: verify outbound decision: %w", err) + } + return intent, result, nil +} + +// verifyEvaluatorAttestation ensures that a residency-bound disclosure is not +// sent until a separately pinned attestor has vouched for the authority's +// evaluator origin. The assertion itself intentionally contains no disclosure +// metadata or payload; it is a short-lived deployment-evidence reference. +func (runtime *Runtime) verifyEvaluatorAttestation(ctx context.Context, client *decisionhttp.Client, residency string) error { + now := time.Now().UTC() + runtime.mu.Lock() + if runtime.outboundAttestationExpiresAt > now.Unix() { + runtime.mu.Unlock() + return nil + } + attestorID := runtime.outboundAttestorID + keyID := runtime.outboundAttestorKeyID + publicKey := append(ed25519.PublicKey(nil), runtime.outboundAttestorPublicKey...) + runtime.mu.Unlock() + + origin, err := client.EvaluatorOrigin() + if err != nil { + return fmt.Errorf("enterprise control: evaluator attestation origin: %w", err) + } + attestation, err := client.EvaluatorAttestation(ctx) + if err != nil { + return fmt.Errorf("enterprise control: fetch evaluator attestation: %w", err) + } + if err := attestation.VerifyForEndpoint(origin, residency, attestorID, keyID, publicKey, now); err != nil { + return fmt.Errorf("enterprise control: verify evaluator attestation: %w", err) + } + runtime.mu.Lock() + if attestation.ExpiresAt > runtime.outboundAttestationExpiresAt { + runtime.outboundAttestationExpiresAt = attestation.ExpiresAt + } + runtime.mu.Unlock() + return nil +} + +// RolloutInterval returns the bounded authority poll cadence. It is zero when +// the attachment does not participate in rollout acknowledgement. +func (runtime *Runtime) RolloutInterval() time.Duration { + if runtime == nil || runtime.rolloutInterval <= 0 { + return 0 + } + return runtime.rolloutInterval +} + +// HasReceiptExport reports whether signed enforcement receipts should be +// asynchronously exported. Export availability never changes local action +// authorization or receipt persistence. +func (runtime *Runtime) HasReceiptExport() bool { + return runtime != nil && runtime.receiptExporter != nil && runtime.receiptInterval > 0 +} + +// ReceiptExportInterval returns the configured bounded export cadence. +func (runtime *Runtime) ReceiptExportInterval() time.Duration { + if runtime == nil || runtime.receiptInterval <= 0 { + return 0 + } + return runtime.receiptInterval +} + +// ExportReceiptsOnce attempts idempotent export of locally durable receipts. +// It is deliberately separate from every governed action path. +func (runtime *Runtime) ExportReceiptsOnce(ctx context.Context) error { + if !runtime.HasReceiptExport() { + return fmt.Errorf("enterprise control: receipt export is not configured") + } + return runtime.receiptExporter.ExportOnce(ctx) +} + +// RefreshRollout fetches a targeted staged candidate and the signed active +// policy from the configured authority. Candidate validation and the staged +// acknowledgement happen before activation; only the authority's current +// policy endpoint can advance the live local ceiling. +func (runtime *Runtime) RefreshRollout(ctx context.Context) error { + if !runtime.HasRollout() { + return fmt.Errorf("enterprise control: rollout participation is not configured") + } + if err := ctx.Err(); err != nil { + return err + } + trust, foundTrust, err := runtime.rolloutClient.CurrentTrust(ctx, runtime.tenantID) + if err != nil { + return err + } + candidate, foundCandidate, err := runtime.rolloutClient.Candidate(ctx, runtime.tenantID, runtime.rolloutAgentID) + if err != nil { + return err + } + current, foundCurrent, err := runtime.rolloutClient.CurrentPolicy(ctx, runtime.tenantID, runtime.rolloutAgentID) + if err != nil { + return err + } + var mandateBundle decision.MandateBundle + foundMandateBundle := false + if runtime.mandateBundlePath != "" { + mandateBundle, foundMandateBundle, err = runtime.rolloutClient.CurrentMandateBundle(ctx, runtime.tenantID, runtime.mandateAgentID) + if err != nil { + return err + } + if !foundMandateBundle { + return fmt.Errorf("enterprise control: authority has no mandate bundle for configured agent") + } + } + runtime.mu.Lock() + defer runtime.mu.Unlock() + if foundTrust { + if err := runtime.installTrustLocked(trust); err != nil { + return err + } + } + if foundCurrent { + if err := runtime.installActivePolicyLocked(ctx, current); err != nil { + return err + } + } + if foundMandateBundle { + if err := runtime.installRemoteMandateBundleLocked(mandateBundle); err != nil { + return err + } + } + if foundCandidate && (!foundCurrent || candidate.Bundle.Revision > current.Bundle.Revision) { + if err := runtime.stageCandidateLocked(ctx, candidate); err != nil { + return err + } + } + return nil +} + +// installTrustLocked root-verifies and persists a remotely supplied trust +// bundle before making it visible to the local enforcer. The Store's durable +// commit hook preserves its strict in-memory monotonicity check, so another +// root-signed bundle at the same revision cannot replace existing trust. +// +// The bundle file is committed before the anti-rollback state. If the process +// stops between those two writes, the next startup sees newer signed trust and +// advances the floor; the reverse order could make recovery impossible. +func (runtime *Runtime) installTrustLocked(bundle authority.TrustBundle) error { + if bundle.TenantID != runtime.tenantID { + return fmt.Errorf("enterprise control: current trust tenant binding mismatch") + } + state, err := loadControlState(runtime.statePath) + if err != nil { + return err + } + if state.TenantID == "" { + return fmt.Errorf("enterprise control: persisted control state is required before installing remote trust") + } + if err := state.acceptsTrust(runtime.tenantID, bundle); err != nil { + return err + } + nextState := controlState{ + TenantID: runtime.tenantID, TrustRevision: bundle.Revision, TrustPolicyRevision: bundle.PolicyRevision, + TrustRevocationEpoch: bundle.RevocationEpoch, PolicyRevision: state.PolicyRevision, PolicyRevocationEpoch: state.PolicyRevocationEpoch, + MandateRevision: state.MandateRevision, MandateRevocationEpoch: state.MandateRevocationEpoch, MandateHash: state.MandateHash, + } + if err := runtime.trust.InstallWithCommit(bundle, func() error { + if err := writeSecureJSON(runtime.trustPath, bundle); err != nil { + return fmt.Errorf("enterprise control: persist current trust: %w", err) + } + return saveControlState(runtime.statePath, nextState) + }); err != nil { + return fmt.Errorf("enterprise control: install current trust: %w", err) + } + return nil +} + +func (runtime *Runtime) stageCandidateLocked(ctx context.Context, candidate authorityhttp.PublicationEnvelope) error { + if candidate.Publication.TenantID != runtime.tenantID || !publicationTargetsAgent(candidate.Publication, runtime.rolloutAgentID) || candidate.Publication.PolicyRevision != candidate.Bundle.Revision || candidate.Publication.RevocationEpoch != candidate.Bundle.RevocationEpoch { + return fmt.Errorf("enterprise control: rollout candidate tenant or revision binding mismatch") + } + issuer, err := runtime.trust.PolicyKey(runtime.tenantID, candidate.Publication.KeyID) + if err != nil { + return fmt.Errorf("enterprise control: resolve candidate policy key: %w", err) + } + if err := candidate.Publication.VerifyFor(candidate.Bundle, issuer, time.Now()); err != nil { + return fmt.Errorf("enterprise control: verify candidate publication: %w", err) + } + if err := runtime.policies.ValidateInstall(ctx, candidate.Bundle); err != nil { + return fmt.Errorf("enterprise control: validate candidate policy: %w", err) + } + ack, err := authority.NewPolicyAcknowledgement(candidate.Publication, runtime.rolloutAgentID, authority.PolicyAckStaged, time.Unix(candidate.Publication.IssuedAt, 0), runtime.rolloutKeyID) + if err != nil { + return fmt.Errorf("enterprise control: create staged acknowledgement: %w", err) + } + if err := ack.Sign(runtime.rolloutPrivate); err != nil { + return fmt.Errorf("enterprise control: sign staged acknowledgement: %w", err) + } + if _, err := runtime.rolloutClient.Acknowledge(ctx, ack); err != nil { + return fmt.Errorf("enterprise control: submit staged acknowledgement: %w", err) + } + return nil +} + +func (runtime *Runtime) installActivePolicyLocked(ctx context.Context, active authorityhttp.ActivePolicyEnvelope) error { + publication, policy, activation := active.Publication, active.Bundle, active.Activation + if policy.TenantID != runtime.tenantID || publication.TenantID != runtime.tenantID || !publicationTargetsAgent(publication, runtime.rolloutAgentID) { + return fmt.Errorf("enterprise control: active policy tenant binding mismatch") + } + issuer, err := runtime.trust.PolicyKey(runtime.tenantID, publication.KeyID) + if err != nil { + return fmt.Errorf("enterprise control: resolve active policy key: %w", err) + } + if err := publication.VerifyFor(policy, issuer, time.Now()); err != nil { + return fmt.Errorf("enterprise control: verify active policy publication: %w", err) + } + if err := activation.VerifyFor(publication, policy, issuer, time.Now()); err != nil { + return fmt.Errorf("enterprise control: verify active policy activation: %w", err) + } + if activation.ActivatesAt > time.Now().Unix() { + return fmt.Errorf("enterprise control: active policy activation time has not arrived") + } + trust, err := runtime.trust.Current(ctx, runtime.tenantID) + if err != nil { + return fmt.Errorf("enterprise control: read active trust: %w", err) + } + state, err := loadControlState(runtime.statePath) + if err != nil { + return err + } + if err := state.accepts(runtime.tenantID, trust, policy); err != nil { + return err + } + nextState := controlState{ + TenantID: runtime.tenantID, TrustRevision: trust.Revision, TrustPolicyRevision: trust.PolicyRevision, + TrustRevocationEpoch: trust.RevocationEpoch, PolicyRevision: policy.Revision, PolicyRevocationEpoch: policy.RevocationEpoch, + MandateRevision: state.MandateRevision, MandateRevocationEpoch: state.MandateRevocationEpoch, MandateHash: state.MandateHash, + } + if err := runtime.policies.InstallWithCommit(ctx, policy, func() error { + if err := writeSecureJSON(runtime.policyPath, policy); err != nil { + return fmt.Errorf("enterprise control: persist active policy: %w", err) + } + return saveControlState(runtime.statePath, nextState) + }); err != nil { + return fmt.Errorf("enterprise control: install active policy: %w", err) + } + return nil +} + +func (runtime *Runtime) installRemoteMandateBundleLocked(bundle decision.MandateBundle) error { + if runtime.mandateBundlePath == "" || runtime.mandates == nil { + return fmt.Errorf("enterprise control: mandate bundle refresh is not configured") + } + if bundle.TenantID != runtime.tenantID || bundle.SubjectAgentID != runtime.mandateAgentID { + return fmt.Errorf("enterprise control: mandate bundle tenant or agent binding mismatch") + } + store, err := decision.NewStaticMandateStoreFromBundle(context.Background(), bundle, runtime.trust, runtime.trust, time.Now()) + if err != nil { + return fmt.Errorf("enterprise control: verify remote mandate bundle: %w", err) + } + return runtime.installMandateBundleLocked(bundle, store) +} + +// installMandateBundleLocked persists the complete signed replacement before +// exposing it to the enforcement ceiling. Its revision/hash floor prevents a +// still-valid lower snapshot from reintroducing a mandate that an empty or +// narrower higher snapshot removed. +func (runtime *Runtime) installMandateBundleLocked(bundle decision.MandateBundle, store decision.MandateStore) error { + if runtime.mandateBundlePath == "" || runtime.mandates == nil { + return fmt.Errorf("enterprise control: mandate bundle store is not initialized") + } + if bundle.TenantID != runtime.tenantID || bundle.SubjectAgentID != runtime.mandateAgentID { + return fmt.Errorf("enterprise control: mandate bundle tenant or agent binding mismatch") + } + hash, err := bundle.Hash() + if err != nil { + return fmt.Errorf("enterprise control: hash mandate bundle: %w", err) + } + state, err := loadControlState(runtime.statePath) + if err != nil { + return err + } + if err := state.acceptsMandate(bundle, hash); err != nil { + return err + } + nextState := state + nextState.MandateRevision = bundle.Revision + nextState.MandateRevocationEpoch = bundle.RevocationEpoch + nextState.MandateHash = hash + if err := writeSecureJSON(runtime.mandateBundlePath, bundle); err != nil { + return fmt.Errorf("enterprise control: persist mandate bundle: %w", err) + } + if err := saveControlState(runtime.statePath, nextState); err != nil { + return err + } + if err := runtime.mandates.Replace(store); err != nil { + return err + } + return nil +} + +func publicationTargetsAgent(publication authority.PolicyPublication, agentID string) bool { + for _, expected := range publication.ExpectedAgents { + if expected == agentID { + return true + } + } + return false +} + +// ApplyDataExchange attaches the verified receiver gate to the supplied +// service config. A nil Runtime leaves the caller's existing config unchanged. +func (runtime *Runtime) ApplyDataExchange(config *dataexchange.ServiceConfig) error { + if runtime == nil || !runtime.dataEnabled { + return nil + } + if config == nil { + return fmt.Errorf("enterprise control: data-exchange service config is required") + } + config.RequireGoverned = runtime.dataRequired + if runtime.dataTransferQuota != nil { + limiter, quotaErr := newTransferQuotaLimiter(*runtime.dataTransferQuota) + if quotaErr != nil { + return quotaErr + } + config.GovernedTransferQuota = limiter + } + if runtime.dataRetention != nil { + policies, interval, retentionErr := dataRetentionPolicies(*runtime.dataRetention) + if retentionErr != nil { + return retentionErr + } + config.GovernedRetentionPolicies = policies + config.RetentionSweepInterval = interval + } + config.GovernedVerifier = dataexchange.DecisionFrameVerifier{ + Enforcer: runtime.enforcer, + Resource: func(_ coreapi.Addr, _ *dataexchange.Frame) string { return runtime.dataResource }, + RequireDisclosure: runtime.dataDisclosureRequired, + } + config.GovernedStreamVerifier = dataexchange.DecisionFrameVerifier{ + Enforcer: runtime.enforcer, + Resource: func(_ coreapi.Addr, _ *dataexchange.Frame) string { return runtime.dataResource }, + RequireDisclosure: runtime.dataDisclosureRequired, + } + if runtime.receipts != nil { + config.RequireGovernedReceipts = runtime.dataRequired + config.GovernedReceiptRecorder = transportReceiptRecorder{signer: runtime.receipts, enforcementPoint: "dataexchange"} + } + return nil +} + +// ApplyEventStream attaches the verified broker publication gate. A nil +// Runtime leaves the caller's service unchanged. +func (runtime *Runtime) ApplyEventStream(service *eventstream.Service) error { + if runtime == nil || !runtime.eventEnabled { + return nil + } + if service == nil { + return fmt.Errorf("enterprise control: event-stream service is required") + } + if runtime.eventTransferQuota != nil { + limiter, quotaErr := newTransferQuotaLimiter(*runtime.eventTransferQuota) + if quotaErr != nil { + return quotaErr + } + service.SetGovernedTransferQuota(limiter) + } + service.SetGovernedPublication(eventstream.DecisionEventVerifier{ + Enforcer: runtime.enforcer, + RequireDisclosure: runtime.eventDisclosureRequired, + Resource: func(_ coreapi.Addr, event *eventstream.Event) string { + return strings.Replace(runtime.eventTemplate, "{topic}", event.Topic, 1) + }, + }, runtime.eventRequired) + if runtime.receipts != nil { + service.SetGovernedReceiptRecorder(transportReceiptRecorder{signer: runtime.receipts, enforcementPoint: "eventstream"}, runtime.eventRequired) + } + return nil +} + +// RequireEnabledServiceGates proves that every enabled transport has a +// mandatory governed rule. The enterprise daemon profile calls this after the +// attachment has been verified, so an omitted or permissive rule cannot leave +// an alternate legacy ingress path open. +func (runtime *Runtime) RequireEnabledServiceGates(dataExchangeEnabled, eventStreamEnabled bool) error { + if dataExchangeEnabled && (runtime == nil || !runtime.dataEnabled || !runtime.dataRequired || runtime.receipts == nil) { + return fmt.Errorf("enterprise control: enabled data exchange requires data_exchange.require_governed=true") + } + if eventStreamEnabled && (runtime == nil || !runtime.eventEnabled || !runtime.eventRequired || runtime.receipts == nil) { + return fmt.Errorf("enterprise control: enabled event stream requires event_stream.require_governed=true") + } + return nil +} + +func validateConfig(config Config) error { + if !identifier(config.TenantID) || !identifier(config.RootKeyID) { + return fmt.Errorf("enterprise control: tenant_id and root_key_id are required identifiers") + } + if strings.TrimSpace(config.RootPublicKey) == "" || strings.TrimSpace(config.TrustBundlePath) == "" || strings.TrimSpace(config.PolicyBundlePath) == "" { + return fmt.Errorf("enterprise control: root_public_key, trust_bundle_path, and policy_bundle_path are required") + } + if config.DataExchange == nil && config.EventStream == nil && config.ActionControl == nil && config.Rollout == nil && config.Receipts == nil && config.Mandates == nil { + return fmt.Errorf("enterprise control: enable at least one governed boundary or control-plane attachment") + } + if config.ActionControl != nil { + registry := actionregistry.Builtins() + if err := config.ActionControl.Profile.Validate(registry); err != nil { + return fmt.Errorf("enterprise control: action_control.profile: %w", err) + } + mode := config.ActionControl.Profile.Mode.Normalize() + agentID := config.ActionControl.AgentID + if agentID == "" && config.OutboundDecisions != nil { + agentID = config.OutboundDecisions.AgentID + } + if mode != actionregistry.ModeOff && !identifier(agentID) { + return fmt.Errorf("enterprise control: enabled action_control requires agent_id") + } + if mode == actionregistry.ModeManagedEnforce { + if config.OutboundDecisions == nil { + return fmt.Errorf("enterprise control: managed action_control requires outbound_decisions") + } + if config.OutboundDecisions.AgentID != agentID { + return fmt.Errorf("enterprise control: action_control and outbound_decisions agent_id must match") + } + federated := false + for _, definition := range registry.Definitions() { + if definition.Privacy == actionregistry.PrivacyFederatedContent && config.ActionControl.Profile.AppliesTo(registry, definition.Name) { + federated = true + break + } + } + if federated { + outbound := config.OutboundDecisions + if outbound.Audience == "" || outbound.Purpose == "" || outbound.EvaluatorResidency == "" { + return fmt.Errorf("enterprise control: managed content actions require hosted federation audience, purpose, and evaluator_residency") + } + labels := append([]string(nil), outbound.ContentLabels...) + if len(labels) == 0 { + labels = []string{"unclassified"} + } + retention := outbound.RetentionClass + if retention == "" { + retention = "exchange-7d" + } + if _, err := decision.NewFederatedContent(decision.DisclosureBinding{ + Version: decision.DisclosureBindingRetentionVersion, ContentHash: decision.HashPayload(nil), + ContentType: "application/octet-stream", Labels: labels, Recipient: outbound.Audience, + Purpose: outbound.Purpose, Residency: outbound.EvaluatorResidency, RetentionClass: retention, + }, nil); err != nil { + return fmt.Errorf("enterprise control: managed hosted federation metadata: %w", err) + } + } + } + if config.ActionControl.ContinuationDirectory != "" { + if mode != actionregistry.ModeManagedEnforce { + return fmt.Errorf("enterprise control: continuation_directory requires managed action_control") + } + if _, err := resolveBundlePath(".", config.ActionControl.ContinuationDirectory); err != nil { + return fmt.Errorf("enterprise control: action_control.continuation_directory: %w", err) + } + } + switch config.ActionControl.Risk { + case "", decision.RiskLow, decision.RiskMedium, decision.RiskHigh, decision.RiskCritical: + default: + return fmt.Errorf("enterprise control: action_control.risk is invalid") + } + } + if config.DataExchange != nil && !validResource(config.DataExchange.Resource) { + return fmt.Errorf("enterprise control: data_exchange.resource must be valid UTF-8 text of 1-1024 bytes") + } + if config.DataExchange != nil && config.DataExchange.RequireDisclosure && !config.DataExchange.RequireGoverned { + return fmt.Errorf("enterprise control: data_exchange.require_disclosure requires require_governed") + } + if config.DataExchange != nil && config.DataExchange.RequireContentInspection { + return fmt.Errorf("enterprise control: data_exchange.require_content_inspection was replaced by Pilot-hosted federation action control") + } + if config.DataExchange != nil && config.DataExchange.TransferQuota != nil { + if !config.DataExchange.RequireGoverned { + return fmt.Errorf("enterprise control: data_exchange.transfer_quota requires require_governed") + } + if _, err := newTransferQuotaLimiter(*config.DataExchange.TransferQuota); err != nil { + return fmt.Errorf("enterprise control: data_exchange.transfer_quota: %w", err) + } + } + if config.DataExchange != nil && config.DataExchange.Retention != nil { + if !config.DataExchange.RequireGoverned || !config.DataExchange.RequireDisclosure { + return fmt.Errorf("enterprise control: data_exchange.retention requires require_governed and require_disclosure") + } + if _, _, err := dataRetentionPolicies(*config.DataExchange.Retention); err != nil { + return fmt.Errorf("enterprise control: data_exchange.retention: %w", err) + } + } + if config.EventStream != nil { + template := config.EventStream.ResourceTemplate + if !validResourceTemplate(template) { + return fmt.Errorf("enterprise control: event_stream.resource_template must be valid UTF-8 text containing exactly one {topic}") + } + } + if config.EventStream != nil && config.EventStream.RequireDisclosure && !config.EventStream.RequireGoverned { + return fmt.Errorf("enterprise control: event_stream.require_disclosure requires require_governed") + } + if config.EventStream != nil && config.EventStream.RequireContentInspection { + return fmt.Errorf("enterprise control: event_stream.require_content_inspection was replaced by Pilot-hosted federation action control") + } + if config.EventStream != nil && config.EventStream.TransferQuota != nil { + if !config.EventStream.RequireGoverned { + return fmt.Errorf("enterprise control: event_stream.transfer_quota requires require_governed") + } + if _, err := newTransferQuotaLimiter(*config.EventStream.TransferQuota); err != nil { + return fmt.Errorf("enterprise control: event_stream.transfer_quota: %w", err) + } + } + if config.Mandates != nil { + legacyPath := strings.TrimSpace(config.Mandates.Path) != "" + bundlePath := strings.TrimSpace(config.Mandates.BundlePath) != "" + if legacyPath == bundlePath { + return fmt.Errorf("enterprise control: mandates requires exactly one of path or bundle_path") + } + if bundlePath { + if !identifier(config.Mandates.AgentID) { + return fmt.Errorf("enterprise control: mandate bundle requires agent_id") + } + if config.Rollout == nil || config.Rollout.AgentID != config.Mandates.AgentID { + return fmt.Errorf("enterprise control: mandate bundle requires rollout for the same agent_id") + } + } + } + if config.Receipts != nil { + if !identifier(config.Receipts.AgentID) || !identifier(config.Receipts.KeyID) || strings.TrimSpace(config.Receipts.SeedPath) == "" || strings.TrimSpace(config.Receipts.JournalPath) == "" { + return fmt.Errorf("enterprise control: receipts requires agent_id, key_id, seed_path, and journal_path") + } + exportConfigured := config.Receipts.ExportEndpoint != "" || config.Receipts.ExportAcknowledgementPath != "" || config.Receipts.ExportBearerTokenEnv != "" || config.Receipts.ExportIntervalSeconds != 0 || config.Receipts.ExportBatchSize != 0 + if exportConfigured { + if strings.TrimSpace(config.Receipts.ExportEndpoint) == "" || strings.TrimSpace(config.Receipts.ExportAcknowledgementPath) == "" { + return fmt.Errorf("enterprise control: receipt export requires export_endpoint and export_acknowledgement_path") + } + if config.Receipts.ExportBearerTokenEnv != "" && !identifier(config.Receipts.ExportBearerTokenEnv) { + return fmt.Errorf("enterprise control: receipt export bearer token environment name is invalid") + } + if config.Receipts.ExportIntervalSeconds < 0 || config.Receipts.ExportIntervalSeconds > 3600 || (config.Receipts.ExportIntervalSeconds > 0 && config.Receipts.ExportIntervalSeconds < 5) { + return fmt.Errorf("enterprise control: receipts.export_interval_seconds must be 0 or 5-3600") + } + if config.Receipts.ExportBatchSize < 0 || config.Receipts.ExportBatchSize > 1000 { + return fmt.Errorf("enterprise control: receipts.export_batch_size must be 0 or 1-1000") + } + } + } + if config.Rollout != nil { + if strings.TrimSpace(config.Rollout.AuthorityEndpoint) == "" || !identifier(config.Rollout.AgentID) || !identifier(config.Rollout.AcknowledgementKeyID) || strings.TrimSpace(config.Rollout.AcknowledgementSeedPath) == "" { + return fmt.Errorf("enterprise control: rollout requires authority_endpoint, agent_id, acknowledgement_key_id, and acknowledgement_seed_path") + } + if config.Rollout.PollIntervalSeconds < 0 || config.Rollout.PollIntervalSeconds > 3600 || (config.Rollout.PollIntervalSeconds > 0 && config.Rollout.PollIntervalSeconds < 5) { + return fmt.Errorf("enterprise control: rollout.poll_interval_seconds must be 0 or 5-3600") + } + } + if config.Fleet != nil { + if config.Rollout == nil { + return fmt.Errorf("enterprise control: fleet requires rollout enrollment") + } + if config.Fleet.ReportIntervalSeconds < 0 || config.Fleet.ReportIntervalSeconds > 3600 || (config.Fleet.ReportIntervalSeconds > 0 && config.Fleet.ReportIntervalSeconds < 5) { + return fmt.Errorf("enterprise control: fleet.report_interval_seconds must be 0 or 5-3600") + } + if config.Fleet.StateSyncIntervalSeconds < 0 || config.Fleet.StateSyncIntervalSeconds > 3600 || (config.Fleet.StateSyncIntervalSeconds > 0 && config.Fleet.StateSyncIntervalSeconds < 2) { + return fmt.Errorf("enterprise control: fleet.state_sync_interval_seconds must be 0 or 2-3600") + } + if !config.Fleet.StateSyncEnabled && (strings.TrimSpace(config.Fleet.StateDirectory) != "" || config.Fleet.StateSyncIntervalSeconds != 0) { + return fmt.Errorf("enterprise control: fleet state directory and interval require state_sync_enabled") + } + if filepath.IsAbs(config.Fleet.StateDirectory) { + return fmt.Errorf("enterprise control: fleet.state_directory must be relative") + } + } + if config.OutboundDecisions != nil { + outbound := config.OutboundDecisions + if strings.TrimSpace(outbound.AuthorityEndpoint) == "" || !identifier(outbound.AgentID) || !identifier(outbound.IntentKeyID) || strings.TrimSpace(outbound.IntentSeedPath) == "" { + return fmt.Errorf("enterprise control: outbound_decisions requires authority_endpoint, agent_id, intent_key_id, and intent_seed_path") + } + switch outbound.Risk { + case "", decision.RiskLow, decision.RiskMedium, decision.RiskHigh, decision.RiskCritical: + default: + return fmt.Errorf("enterprise control: outbound_decisions.risk is invalid") + } + if outbound.RequestTimeoutSeconds < 0 || outbound.RequestTimeoutSeconds > 60 { + return fmt.Errorf("enterprise control: outbound_decisions.request_timeout_seconds must be 0-60") + } + if outbound.EvaluatorResidency != "" && !validEnterpriseResidency(outbound.EvaluatorResidency) { + return fmt.Errorf("enterprise control: outbound_decisions.evaluator_residency is invalid") + } + if outbound.EvaluatorAttestation != nil { + attestor := outbound.EvaluatorAttestation + if outbound.EvaluatorResidency == "" || !identifier(attestor.AttestorID) || !identifier(attestor.KeyID) { + return fmt.Errorf("enterprise control: evaluator_attestation requires evaluator_residency, attestor_id, and key_id") + } + if _, err := decodeEvaluatorAttestorKey(attestor.PublicKey); err != nil { + return err + } + } + if (outbound.Audience == "") != (outbound.Purpose == "") || outbound.Purpose != "" && !validResource(outbound.Purpose) { + return fmt.Errorf("enterprise control: outbound_decisions audience and purpose must be configured together") + } + if outbound.MandateID != "" { + if !identifier(outbound.MandateID) || outbound.Audience == "" || config.Mandates == nil { + return fmt.Errorf("enterprise control: outbound_decisions mandate_id requires audience, purpose, and mandates") + } + } + } + if config.ContentInspection != nil { + return fmt.Errorf("enterprise control: content_inspection is no longer supported; use managed Pilot-hosted federation action control") + } + return nil +} + +func newTransferQuotaLimiter(config TransferQuotaConfig) (*decision.TransferQuotaLimiter, error) { + limiter, err := decision.NewTransferQuotaLimiter(decision.TransferQuotaConfig{ + Window: time.Duration(config.WindowSeconds) * time.Second, + MaxBytes: config.MaxBytes, + MaxActions: config.MaxActions, + MaxSenders: config.MaxSenders, + }) + if err != nil { + return nil, err + } + return limiter, nil +} + +func dataRetentionPolicies(config DataRetentionConfig) ([]dataexchange.GovernedRetentionPolicy, time.Duration, error) { + if len(config.Classes) == 0 || len(config.Classes) > 32 { + return nil, 0, fmt.Errorf("retention requires 1-32 classes") + } + policies := make([]dataexchange.GovernedRetentionPolicy, 0, len(config.Classes)) + seen := make(map[string]struct{}, len(config.Classes)) + for _, configured := range config.Classes { + if !validRetentionClass(configured.Class) || configured.RetainForSeconds < 1 || configured.RetainForSeconds > int64((10*365*24*time.Hour)/time.Second) { + return nil, 0, fmt.Errorf("invalid retention class") + } + if _, exists := seen[configured.Class]; exists { + return nil, 0, fmt.Errorf("duplicate retention class %q", configured.Class) + } + seen[configured.Class] = struct{}{} + policies = append(policies, dataexchange.GovernedRetentionPolicy{Class: configured.Class, RetainFor: time.Duration(configured.RetainForSeconds) * time.Second}) + } + interval := time.Duration(config.SweepIntervalSeconds) * time.Second + if config.SweepIntervalSeconds != 0 && (interval < time.Second || interval > 24*time.Hour) { + return nil, 0, fmt.Errorf("sweep_interval_seconds must be 0 or 1-86400") + } + return policies, interval, nil +} + +func validRetentionClass(value string) bool { + if len(value) == 0 || len(value) > 64 || value[0] == '-' || value[len(value)-1] == '-' { + return false + } + for index, character := range value { + if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || (character == '-' && index > 0 && index+1 < len(value)) { + continue + } + return false + } + return true +} + +func validEnterpriseResidency(value string) bool { + if len(value) == 0 || len(value) > 64 || value[0] == '-' || value[len(value)-1] == '-' { + return false + } + for _, character := range value { + if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || character == '-' { + continue + } + return false + } + return true +} + +func decodeRoot(encoded string) (ed25519.PublicKey, error) { + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil || len(decoded) != ed25519.PublicKeySize || base64.StdEncoding.EncodeToString(decoded) != encoded { + return nil, fmt.Errorf("enterprise control: root_public_key must be canonical base64 Ed25519") + } + return ed25519.PublicKey(decoded), nil +} + +func decodeEvaluatorAttestorKey(encoded string) (ed25519.PublicKey, error) { + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil || len(decoded) != ed25519.PublicKeySize || base64.StdEncoding.EncodeToString(decoded) != encoded { + return nil, fmt.Errorf("enterprise control: evaluator_attestation.public_key must be canonical base64 Ed25519") + } + return ed25519.PublicKey(decoded), nil +} + +func resolveBundlePath(directory, configured string) (string, error) { + if strings.TrimSpace(configured) == "" { + return "", fmt.Errorf("path is required") + } + if filepath.IsAbs(configured) { + return "", fmt.Errorf("absolute paths are not allowed") + } + cleaned := filepath.Clean(configured) + if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("path escapes control configuration directory") + } + return filepath.Join(directory, cleaned), nil +} + +func readSecureJSON[T any](path string) (T, error) { + var value T + contents, err := readSecureBytes(path) + if err != nil { + return value, err + } + decoder := json.NewDecoder(bytes.NewReader(contents)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return value, fmt.Errorf("trailing JSON value") + } + return value, fmt.Errorf("trailing data: %w", err) + } + return value, nil +} + +func readSecureBytes(path string) ([]byte, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("must be a regular file, not a symlink") + } + if info.Mode().Perm()&0o022 != 0 { + return nil, fmt.Errorf("must not be group- or world-writable") + } + contents, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return contents, nil +} + +func secureDirectory(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("is not a directory") + } + if info.Mode().Perm()&0o022 != 0 { + return fmt.Errorf("must not be group- or world-writable") + } + return nil +} + +func loadControlState(path string) (controlState, error) { + if _, err := os.Lstat(path); err != nil { + if os.IsNotExist(err) { + return controlState{}, nil + } + return controlState{}, fmt.Errorf("enterprise control: read persisted state: %w", err) + } + state, err := readSecureJSON[controlState](path) + if err != nil { + return controlState{}, fmt.Errorf("enterprise control: read persisted state: %w", err) + } + if !identifier(state.TenantID) || state.TrustRevision == 0 || state.TrustPolicyRevision == 0 || state.TrustRevocationEpoch == 0 || state.PolicyRevision == 0 || state.PolicyRevocationEpoch == 0 { + return controlState{}, fmt.Errorf("enterprise control: persisted state is invalid") + } + if (state.MandateRevision == 0) != (state.MandateHash == "") || (state.MandateRevision == 0) != (state.MandateRevocationEpoch == 0) || (state.MandateHash != "" && !lowerHex(state.MandateHash, 64)) { + return controlState{}, fmt.Errorf("enterprise control: persisted mandate state is invalid") + } + return state, nil +} + +func (state controlState) accepts(tenantID string, trust authority.TrustBundle, policy authority.PolicyBundle) error { + if err := state.acceptsTrust(tenantID, trust); err != nil { + return err + } + if state.TenantID != "" && (policy.Revision < state.PolicyRevision || policy.RevocationEpoch < state.PolicyRevocationEpoch) { + return fmt.Errorf("enterprise control: signed state is below the persisted rollback floor") + } + return nil +} + +func (state controlState) acceptsTrust(tenantID string, trust authority.TrustBundle) error { + if state.TenantID == "" { + return nil + } + if state.TenantID != tenantID { + return fmt.Errorf("enterprise control: persisted state belongs to a different tenant") + } + if trust.Revision < state.TrustRevision || trust.PolicyRevision < state.TrustPolicyRevision || trust.RevocationEpoch < state.TrustRevocationEpoch { + return fmt.Errorf("enterprise control: signed state is below the persisted rollback floor") + } + return nil +} + +func (state controlState) acceptsMandate(bundle decision.MandateBundle, hash string) error { + if state.TenantID == "" { + return fmt.Errorf("enterprise control: persisted control state is required before installing mandate bundle") + } + if bundle.Revision < state.MandateRevision || bundle.RevocationEpoch < state.MandateRevocationEpoch || (bundle.Revision == state.MandateRevision && state.MandateHash != "" && hash != state.MandateHash) { + return fmt.Errorf("enterprise control: mandate bundle is below the persisted rollback floor") + } + return nil +} + +func lowerHex(value string, expectedLength int) bool { + if len(value) != expectedLength { + return false + } + for _, character := range value { + if (character < '0' || character > '9') && (character < 'a' || character > 'f') { + return false + } + } + return true +} + +func readEd25519Seed(path string) (ed25519.PrivateKey, error) { + contents, err := readSecureBytes(path) + if err != nil { + return nil, err + } + encoded := strings.TrimSpace(string(contents)) + seed, err := base64.StdEncoding.DecodeString(encoded) + if err != nil || len(seed) != ed25519.SeedSize || base64.StdEncoding.EncodeToString(seed) != encoded { + return nil, fmt.Errorf("must be canonical base64 Ed25519 seed") + } + return ed25519.NewKeyFromSeed(seed), nil +} + +func saveControlState(path string, state controlState) error { + contents, err := json.Marshal(state) + if err != nil { + return fmt.Errorf("enterprise control: encode persisted state: %w", err) + } + if err := atomicWriteSecureBytes(path, contents); err != nil { + return fmt.Errorf("enterprise control: persist state: %w", err) + } + return nil +} + +func writeSecureJSON(path string, value any) error { + contents, err := json.Marshal(value) + if err != nil { + return err + } + return atomicWriteSecureBytes(path, contents) +} + +// atomicWriteSecureBytes replaces a protected attachment file without ever +// following a symlink. The sibling temporary file and parent-directory sync +// make a successfully returned update recoverable across a daemon restart. +func atomicWriteSecureBytes(path string, contents []byte) error { + directory := filepath.Dir(path) + if err := secureDirectory(directory); err != nil { + return fmt.Errorf("protect parent directory: %w", err) + } + if info, err := os.Lstat(path); err == nil { + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("target must be a regular file, not a symlink") + } + } else if !os.IsNotExist(err) { + return err + } + temporary, err := os.CreateTemp(directory, ".enterprise-control-state-*") + if err != nil { + return fmt.Errorf("create temporary file: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return fmt.Errorf("protect temporary file: %w", err) + } + if _, err := temporary.Write(contents); err != nil { + _ = temporary.Close() + return fmt.Errorf("write temporary file: %w", err) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return fmt.Errorf("sync temporary file: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close temporary file: %w", err) + } + if err := os.Rename(temporaryPath, path); err != nil { + return fmt.Errorf("commit temporary file: %w", err) + } + directoryFile, err := os.Open(directory) + if err != nil { + return fmt.Errorf("open parent directory: %w", err) + } + defer directoryFile.Close() + if err := directoryFile.Sync(); err != nil { + return fmt.Errorf("sync parent directory: %w", err) + } + return nil +} + +func identifier(value string) bool { + if len(value) == 0 || len(value) > 128 || !utf8.ValidString(value) { + return false + } + for _, character := range value { + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || character == '-' || character == '_' || character == '.' { + continue + } + return false + } + return true +} + +func validResource(value string) bool { + return len(value) > 0 && len(value) <= 1024 && utf8.ValidString(value) +} + +func validResourceTemplate(value string) bool { + return validResource(value) && strings.Count(value, "{topic}") == 1 +} diff --git a/internal/enterprisecontrol/control_test.go b/internal/enterprisecontrol/control_test.go new file mode 100644 index 00000000..0ed29df4 --- /dev/null +++ b/internal/enterprisecontrol/control_test.go @@ -0,0 +1,1746 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package enterprisecontrol + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/pilot-protocol/common/actionhook" + "github.com/pilot-protocol/common/actionregistry" + "github.com/pilot-protocol/common/authority" + "github.com/pilot-protocol/common/authorityhttp" + "github.com/pilot-protocol/common/coreapi" + "github.com/pilot-protocol/common/decision" + "github.com/pilot-protocol/common/decisionhttp" + "github.com/pilot-protocol/common/decisionpolicy" + "github.com/pilot-protocol/dataexchange" + "github.com/pilot-protocol/eventstream" +) + +type controlFixture struct { + path string + rootPrivate ed25519.PrivateKey + intentPrivate ed25519.PrivateKey + decisionPrivate ed25519.PrivateKey + now time.Time +} + +type controlAuthorizerFunc func(context.Context, decision.Intent) (decision.Decision, error) + +func (authorize controlAuthorizerFunc) Authorize(ctx context.Context, intent decision.Intent) (decision.Decision, error) { + return authorize(ctx, intent) +} + +func newControlFixture(t *testing.T) controlFixture { + t.Helper() + directory := t.TempDir() + rootPublic, rootPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate root key: %v", err) + } + intentPublic, intentPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate intent key: %v", err) + } + decisionPublic, decisionPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate authority key: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + trust := authority.TrustBundle{ + Version: authority.SchemaVersion, TenantID: "tenant-a", Revision: 1, PolicyRevision: 1, RevocationEpoch: 1, + IssuedAt: now.Add(-time.Minute).Unix(), ExpiresAt: now.Add(time.Hour).Unix(), RootKeyID: "root-key", + Keys: []authority.AuthorityKey{ + {KeyID: "sender-key", AgentID: "sender-a", PublicKey: base64.StdEncoding.EncodeToString(intentPublic), Usages: []authority.KeyUsage{authority.UsageIntent}, NotBefore: now.Add(-time.Minute).Unix(), ExpiresAt: now.Add(time.Hour).Unix()}, + {KeyID: "receiver-receipt-key", AgentID: "receiver-a", PublicKey: base64.StdEncoding.EncodeToString(intentPublic), Usages: []authority.KeyUsage{authority.UsageReceipt}, NotBefore: now.Add(-time.Minute).Unix(), ExpiresAt: now.Add(time.Hour).Unix()}, + {KeyID: "authority-key", PublicKey: base64.StdEncoding.EncodeToString(decisionPublic), Usages: []authority.KeyUsage{authority.UsageDecision, authority.UsagePolicy, authority.UsageMandate}, NotBefore: now.Add(-time.Minute).Unix(), ExpiresAt: now.Add(time.Hour).Unix()}, + }, + } + if err := trust.Sign(rootPrivate); err != nil { + t.Fatalf("sign trust: %v", err) + } + policyPayload, err := json.Marshal(decisionpolicy.Document{ + Version: 1, DefaultOutcome: decision.Deny, + Rules: []decisionpolicy.Rule{ + {ID: "text-to-inbox", Agents: []string{"*"}, Actions: []string{"data.send.text"}, ResourcePrefixes: []string{"agent:receiver/inbox"}, Risks: allRisks(), Outcome: decision.Allow}, + {ID: "event-publication", Agents: []string{"*"}, Actions: []string{"event.publish"}, ResourcePrefixes: []string{"eventstream:"}, Risks: allRisks(), Outcome: decision.Allow}, + }, + }) + if err != nil { + t.Fatalf("marshal policy: %v", err) + } + policy := authority.NewPolicyBundle("tenant-a", 1, 1, now.Add(-time.Minute), now.Add(-time.Minute), now.Add(time.Hour), decisionpolicy.Engine, decisionpolicy.EngineVersion, decisionpolicy.ContentType, "authority-key", policyPayload) + if err := policy.Sign(decisionPrivate); err != nil { + t.Fatalf("sign policy: %v", err) + } + writeControlJSON(t, filepath.Join(directory, "trust.json"), trust) + writeControlJSON(t, filepath.Join(directory, "policy.json"), policy) + config := Config{ + TenantID: "tenant-a", RootKeyID: "root-key", RootPublicKey: base64.StdEncoding.EncodeToString(rootPublic), + TrustBundlePath: "trust.json", PolicyBundlePath: "policy.json", + DataExchange: &DataExchangeRule{RequireGoverned: true, Resource: "agent:receiver/inbox"}, + EventStream: &EventStreamRule{RequireGoverned: true, ResourceTemplate: "eventstream:{topic}"}, + } + path := filepath.Join(directory, "control.json") + writeControlJSON(t, path, config) + return controlFixture{path: path, rootPrivate: rootPrivate, intentPrivate: intentPrivate, decisionPrivate: decisionPrivate, now: now} +} + +func allRisks() []decision.RiskClass { + return []decision.RiskClass{decision.RiskLow, decision.RiskMedium, decision.RiskHigh, decision.RiskCritical} +} + +func TestLocalActionControlIsExplicitAndEnforcesSignedPolicy(t *testing.T) { + fixture := newControlFixture(t) + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.ActionControl = &ActionControlConfig{ + Profile: actionregistry.Profile{Version: actionregistry.SchemaVersion, Mode: actionregistry.ModeLocalEnforce, Actions: []string{"trust.accept", "trust.auto_accept"}}, + AgentID: "sender-a", Risk: decision.RiskHigh, + } + writeControlJSON(t, fixture.path, config) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + hook := runtime.ActionHook() + if hook == nil { + t.Fatal("explicit local action profile did not attach a hook") + } + envelope, err := actionhook.NewEnvelope("trust.accept", "agent:42", actionhook.HashMetadata(map[string]string{"peer_node_id": "42"}), "pilot.handshake", map[string]string{"peer_node_id": "42"}, time.Now()) + if err != nil { + t.Fatal(err) + } + preflight, err := hook.BeforeAction(context.Background(), envelope) + if err != nil { + t.Fatal(err) + } + if preflight.Outcome != decision.Deny || preflight.ObserveOnly { + t.Fatalf("signed default-deny policy was not enforced: %+v", preflight) + } + + unselected, err := actionhook.NewEnvelope("trust.request", "agent:42", envelope.PayloadHash, "pilot.handshake", nil, time.Now()) + if err != nil { + t.Fatal(err) + } + pass, err := hook.BeforeAction(context.Background(), unselected) + if err != nil || !pass.ObserveOnly || pass.Outcome != decision.Allow { + t.Fatalf("unselected action must remain unmanaged: preflight=%+v err=%v", pass, err) + } +} + +func TestAbsentOrOffActionControlDoesNotAttachHook(t *testing.T) { + fixture := newControlFixture(t) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + if runtime.ActionHook() != nil { + t.Fatal("legacy attachment unexpectedly enabled action hooks") + } + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.ActionControl = &ActionControlConfig{Profile: actionregistry.Profile{Mode: actionregistry.ModeOff}} + writeControlJSON(t, fixture.path, config) + runtime, err = Load(fixture.path) + if err != nil { + t.Fatal(err) + } + if runtime.ActionHook() != nil { + t.Fatal("off action profile unexpectedly enabled hooks") + } +} + +func TestManagedActionApprovalSuspendsAndResumesExactlyOnce(t *testing.T) { + fixture := newControlFixture(t) + directory := filepath.Dir(fixture.path) + approvalPublic, approvalPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + trust, err := readSecureJSON[authority.TrustBundle](filepath.Join(directory, "trust.json")) + if err != nil { + t.Fatal(err) + } + trust.Keys = append(trust.Keys, authority.AuthorityKey{ + KeyID: "approval-key", PublicKey: base64.StdEncoding.EncodeToString(approvalPublic), Usages: []authority.KeyUsage{authority.UsageApproval}, + NotBefore: fixture.now.Add(-time.Minute).Unix(), ExpiresAt: fixture.now.Add(time.Hour).Unix(), + }) + if err := trust.Sign(fixture.rootPrivate); err != nil { + t.Fatal(err) + } + writeControlJSON(t, filepath.Join(directory, "trust.json"), trust) + + policy, err := readSecureJSON[authority.PolicyBundle](filepath.Join(directory, "policy.json")) + if err != nil { + t.Fatal(err) + } + var document decisionpolicy.Document + if err := json.Unmarshal(policy.Payload, &document); err != nil { + t.Fatal(err) + } + document.Rules = append([]decisionpolicy.Rule{{ + ID: "allow-trust-accept", Agents: []string{"sender-a"}, Actions: []string{"trust.accept"}, + ResourcePrefixes: []string{"agent:"}, Risks: allRisks(), Outcome: decision.Allow, + }}, document.Rules...) + payload, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + policy = authority.NewPolicyBundle("tenant-a", 1, 1, fixture.now.Add(-time.Minute), fixture.now.Add(-time.Minute), fixture.now.Add(time.Hour), decisionpolicy.Engine, decisionpolicy.EngineVersion, decisionpolicy.ContentType, "authority-key", payload) + if err := policy.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + writeControlJSON(t, filepath.Join(directory, "policy.json"), policy) + + rootPublicRaw, err := base64.StdEncoding.DecodeString(readControlConfig(t, fixture.path).RootPublicKey) + if err != nil { + t.Fatal(err) + } + keys, err := authority.NewStore([]authority.PinnedRoot{{TenantID: "tenant-a", RootKeyID: "root-key", PublicKey: ed25519.PublicKey(rootPublicRaw)}}, time.Now) + if err != nil { + t.Fatal(err) + } + if err := keys.Install(trust); err != nil { + t.Fatal(err) + } + decisionStore, err := decisionhttp.NewFileDecisionStore(filepath.Join(directory, "authority-decisions")) + if err != nil { + t.Fatal(err) + } + authorizer, err := decisionhttp.NewHandler(decisionhttp.HandlerConfig{ + Evaluator: controlAuthorizerFunc(func(context.Context, decision.Intent) (decision.Decision, error) { + return decision.Decision{Outcome: decision.ApprovalRequired, Reasons: []string{"approval-plan:trust-review:1"}, PolicyRevision: 1, RevocationEpoch: 1}, nil + }), + IntentKeys: keys, ProviderID: "test-authority", KeyID: "authority-key", PrivateKey: fixture.decisionPrivate, Store: decisionStore, + }) + if err != nil { + t.Fatal(err) + } + workflowStore, err := decisionhttp.NewFileWorkflowStore(filepath.Join(directory, "authority-workflows")) + if err != nil { + t.Fatal(err) + } + workflows, err := decisionhttp.NewWorkflowService(decisionhttp.WorkflowServiceConfig{ + Authority: authorizer, Keys: keys, Store: workflowStore, + Planner: decisionhttp.StaticWorkflowPlanner{ApprovalPlan: decisionhttp.ApprovalPlan{ + Outcome: decision.Allow, ApproverKeyIDs: []string{"approval-key"}, Required: 1, Validity: time.Hour, + }}, + }) + if err != nil { + t.Fatal(err) + } + workflowHandler, err := decisionhttp.NewWorkflowHandler(workflows) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + mux.Handle("/v1/authorize", authorizer) + mux.HandleFunc("/v1/workflow/begin", workflowHandler.Begin) + mux.HandleFunc("/v1/workflow/vote", workflowHandler.Vote) + mux.HandleFunc("/v1/workflow/execute", workflowHandler.Execute) + mux.HandleFunc("/v1/workflow-status", workflowHandler.Status) + server := httptest.NewServer(mux) + defer server.Close() + + seedPath := filepath.Join(directory, "sender-intent.seed") + if err := os.WriteFile(seedPath, []byte(base64.StdEncoding.EncodeToString(fixture.intentPrivate.Seed())), 0o600); err != nil { + t.Fatal(err) + } + config := readControlConfig(t, fixture.path) + config.OutboundDecisions = &OutboundDecisionConfig{ + AuthorityEndpoint: server.URL, AgentID: "sender-a", IntentKeyID: "sender-key", IntentSeedPath: filepath.Base(seedPath), Risk: decision.RiskHigh, + } + config.ActionControl = &ActionControlConfig{ + Profile: actionregistry.Profile{Version: actionregistry.SchemaVersion, Mode: actionregistry.ModeManagedEnforce, Actions: []string{"trust.accept"}}, + AgentID: "sender-a", ContinuationDirectory: "continuations", + } + writeControlJSON(t, fixture.path, config) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + newEnvelope := func() actionhook.Envelope { + envelope, envelopeErr := actionhook.NewEnvelope("trust.accept", "agent:42", actionhook.HashMetadata(map[string]string{"peer_node_id": "42"}), "pilot.handshake", map[string]string{"peer_node_id": "42"}, time.Now()) + if envelopeErr != nil { + t.Fatal(envelopeErr) + } + envelope.ResumeToken = "trust.accept:inbound:42" + return envelope + } + + initial, err := runtime.BeforeAction(context.Background(), newEnvelope()) + if err != nil || initial.Outcome != decision.ApprovalRequired || initial.Reference.ApprovalTransaction == "" { + t.Fatalf("initial preflight=%+v err=%v", initial, err) + } + workflow, err := workflows.Status(context.Background(), initial.Reference.ApprovalTransaction) + if err != nil { + t.Fatal(err) + } + vote, err := decision.NewApprovalVote(workflow.Transaction, "security-owner", decision.ApprovalVoteApprove, time.Now(), time.Now().Add(50*time.Minute), "11111111111111111111111111111111", "approval-key") + if err != nil { + t.Fatal(err) + } + if err := vote.Sign(approvalPrivate); err != nil { + t.Fatal(err) + } + if _, err := workflows.Vote(context.Background(), workflow.Transaction.ID, vote); err != nil { + t.Fatal(err) + } + + resumedEnvelope := newEnvelope() + resumed, err := runtime.BeforeAction(context.Background(), resumedEnvelope) + if err != nil || resumed.Outcome != decision.Allow || resumed.Reference.ApprovalTransaction != workflow.Transaction.ID { + t.Fatalf("resumed preflight=%+v err=%v", resumed, err) + } + if _, err := runtime.BeforeAction(context.Background(), newEnvelope()); err == nil || !strings.Contains(err.Error(), "already executing") { + t.Fatalf("concurrent duplicate resume err=%v", err) + } + if err := runtime.AfterAction(context.Background(), resumedEnvelope, resumed, actionhook.ObservedResult{Status: actionhook.StatusSucceeded, ObservedAt: time.Now().Unix()}); err != nil { + t.Fatal(err) + } + + // A later intentional repetition gets a new approval transaction; the + // successful historical record remains immutable evidence. + repeated, err := runtime.BeforeAction(context.Background(), newEnvelope()) + if err != nil || repeated.Outcome != decision.ApprovalRequired || repeated.Reference.ApprovalTransaction == workflow.Transaction.ID { + t.Fatalf("repeated action preflight=%+v err=%v", repeated, err) + } +} + +func readControlConfig(t *testing.T, path string) Config { + t.Helper() + config, err := readSecureJSON[Config](path) + if err != nil { + t.Fatal(err) + } + return config +} + +func TestDisclosureRequirementNeedsGovernedBoundary(t *testing.T) { + base := Config{ + TenantID: "tenant-a", RootKeyID: "root-key", RootPublicKey: "configured", TrustBundlePath: "trust.json", PolicyBundlePath: "policy.json", + DataExchange: &DataExchangeRule{RequireDisclosure: true, Resource: "agent:receiver/inbox"}, + } + if err := validateConfig(base); err == nil || !strings.Contains(err.Error(), "require_disclosure requires") { + t.Fatalf("data disclosure without governed boundary err=%v", err) + } + base.DataExchange = &DataExchangeRule{RequireGoverned: true, RequireDisclosure: true, Resource: "agent:receiver/inbox"} + if err := validateConfig(base); err != nil { + t.Fatalf("data disclosure governed config err=%v", err) + } + base.DataExchange = nil + base.EventStream = &EventStreamRule{RequireDisclosure: true, ResourceTemplate: "eventstream:{topic}"} + if err := validateConfig(base); err == nil || !strings.Contains(err.Error(), "require_disclosure requires") { + t.Fatalf("event disclosure without governed boundary err=%v", err) + } +} + +func TestTypedDisclosurePolicyIsEnforcedAtDataBoundary(t *testing.T) { + fixture := newControlFixture(t) + directory := filepath.Dir(fixture.path) + payload, err := json.Marshal(decisionpolicy.Document{ + Version: 1, DefaultOutcome: decision.Deny, + Rules: []decisionpolicy.Rule{{ + ID: "eu-finance-message", Agents: []string{"sender-a"}, Actions: []string{"data.send.text"}, ResourcePrefixes: []string{"agent:receiver/inbox"}, + Risks: []decision.RiskClass{decision.RiskHigh}, Outcome: decision.Allow, + Disclosure: &decisionpolicy.DisclosureRule{ + LabelsAll: []string{"finance", "pii"}, ContentTypes: []string{"text/plain"}, Recipients: []string{"agent:receiver"}, + Purposes: []string{"inbox-delivery"}, Residencies: []string{"eu-west-1"}, + }, + }}, + }) + if err != nil { + t.Fatal(err) + } + policy := authority.NewPolicyBundle("tenant-a", 1, 1, fixture.now.Add(-time.Minute), fixture.now.Add(-time.Minute), fixture.now.Add(time.Hour), decisionpolicy.Engine, decisionpolicy.EngineVersion, decisionpolicy.ContentType, "authority-key", payload) + if err := policy.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + writeControlJSON(t, filepath.Join(directory, "policy.json"), policy) + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.DataExchange.RequireDisclosure = true + writeControlJSON(t, fixture.path, config) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + serviceConfig := dataexchange.ServiceConfig{} + if err := runtime.ApplyDataExchange(&serviceConfig); err != nil { + t.Fatal(err) + } + frame := &dataexchange.Frame{Type: dataexchange.TypeText, Payload: []byte("classified invoice")} + build := func(residency, suffix string) dataexchange.GovernedFrame { + disclosure := decision.DisclosureBinding{ + Version: decision.DisclosureBindingVersion, ContentHash: decision.HashPayload(frame.Payload), DeclaredBytes: uint64(len(frame.Payload)), + ContentType: "text/plain", Labels: []string{"finance", "pii"}, Recipient: "agent:receiver", Purpose: "inbox-delivery", Residency: residency, + } + hash, hashErr := disclosure.Hash() + if hashErr != nil { + t.Fatal(hashErr) + } + nonce, nonceErr := decision.NewNonce() + if nonceErr != nil { + t.Fatal(nonceErr) + } + intent := decision.Intent{ + Version: decision.SchemaVersion, ID: "typed-data-intent-" + suffix, TenantID: "tenant-a", AgentID: "sender-a", + Action: "data.send.text", Resource: "agent:receiver/inbox", Audience: disclosure.Recipient, Purpose: disclosure.Purpose, + PayloadHash: hash, Risk: decision.RiskHigh, IssuedAt: fixture.now.Unix(), ExpiresAt: fixture.now.Add(time.Minute).Unix(), Nonce: nonce, KeyID: "sender-key", + } + if signErr := intent.Sign(fixture.intentPrivate); signErr != nil { + t.Fatal(signErr) + } + intentHash, hashErr := intent.Hash() + if hashErr != nil { + t.Fatal(hashErr) + } + result := decision.Decision{ + Version: decision.SchemaVersion, ID: "typed-data-decision-" + suffix, IntentHash: intentHash, TenantID: intent.TenantID, AgentID: intent.AgentID, + Outcome: decision.Allow, PolicyRevision: 1, RevocationEpoch: 1, ProviderID: "authority-a", IssuedAt: fixture.now.Unix(), ExpiresAt: fixture.now.Add(time.Minute).Unix(), KeyID: "authority-key", + } + if signErr := result.Sign(fixture.decisionPrivate); signErr != nil { + t.Fatal(signErr) + } + governed, buildErr := dataexchange.NewGovernedFrameWithDisclosure(frame, intent, result, disclosure) + if buildErr != nil { + t.Fatal(buildErr) + } + return governed + } + if err := serviceConfig.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, build("eu-west-1", "eu")); err != nil { + t.Fatalf("approved EU disclosure rejected: %v", err) + } + if err := serviceConfig.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, build("us-east-1", "us")); err == nil || !strings.Contains(err.Error(), "expands local deny") { + t.Fatalf("US disclosure expanded local policy: %v", err) + } +} + +func writeControlJSON(t *testing.T, path string, value any) { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal %s: %v", path, err) + } + if err := os.WriteFile(path, encoded, 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func TestLoadAttachesSignedControlsAndEnforcesLocalPolicy(t *testing.T) { + fixture := newControlFixture(t) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatalf("load control: %v", err) + } + dataConfig := dataexchange.ServiceConfig{} + if err := runtime.ApplyDataExchange(&dataConfig); err != nil { + t.Fatalf("apply data exchange: %v", err) + } + if !dataConfig.RequireGoverned || dataConfig.GovernedVerifier == nil || dataConfig.GovernedStreamVerifier == nil { + t.Fatalf("data exchange was not configured as a required governed receiver") + } + eventService := eventstream.NewService() + if err := runtime.ApplyEventStream(eventService); err != nil { + t.Fatalf("apply event stream: %v", err) + } + state, err := readSecureJSON[controlState](filepath.Join(filepath.Dir(fixture.path), ".enterprise-control-state.json")) + if err != nil || state.TenantID != "tenant-a" || state.TrustRevision != 1 || state.TrustPolicyRevision != 1 || state.TrustRevocationEpoch != 1 || state.PolicyRevision != 1 || state.PolicyRevocationEpoch != 1 { + t.Fatalf("persisted state = %+v, err=%v", state, err) + } + + allowed := signedFrameForControl(t, fixture, &dataexchange.Frame{Type: dataexchange.TypeText, Payload: []byte("approved")}, 1) + if err := dataConfig.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, allowed); err != nil { + t.Fatalf("configured verifier rejected allowed text: %v", err) + } + denied := signedFrameForControl(t, fixture, &dataexchange.Frame{Type: dataexchange.TypeBinary, Payload: []byte("blocked")}, 1) + if err := dataConfig.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, denied); err == nil || !strings.Contains(err.Error(), "local authority ceiling") { + t.Fatalf("configured verifier error = %v, want local policy denial", err) + } + writeControlRevision(t, fixture, 2, true) + if err := runtime.Reload(); err != nil { + t.Fatalf("reload signed state: %v", err) + } + allowedAfterReload := signedFrameForControl(t, fixture, &dataexchange.Frame{Type: dataexchange.TypeBinary, Payload: []byte("now-approved")}, 2) + if err := dataConfig.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, allowedAfterReload); err != nil { + t.Fatalf("configured verifier rejected policy added by reload: %v", err) + } +} + +func TestMandateAttachmentRequiresAValidBoundMandateAtDataBoundary(t *testing.T) { + fixture := newControlFixture(t) + mandate := configureControlMandate(t, fixture) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + dataConfig := dataexchange.ServiceConfig{} + if err := runtime.ApplyDataExchange(&dataConfig); err != nil { + t.Fatal(err) + } + missing := signedFrameForControl(t, fixture, &dataexchange.Frame{Type: dataexchange.TypeText, Payload: []byte("without mandate")}, 1) + if err := dataConfig.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, missing); err == nil || !strings.Contains(err.Error(), "mandate is required") { + t.Fatalf("missing mandate error=%v", err) + } + allowed := signedMandatedFrameForControl(t, fixture, mandate, &dataexchange.Frame{Type: dataexchange.TypeText, Payload: []byte("with mandate")}, 1) + if err := dataConfig.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, allowed); err != nil { + t.Fatalf("valid mandated frame rejected: %v", err) + } +} + +func TestMandateAttachmentRequiresAValidBoundMandateAtEventBoundary(t *testing.T) { + fixture := newControlFixture(t) + mandate := configureEventControlMandate(t, fixture) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + service := eventstream.NewService() + if err := runtime.ApplyEventStream(service); err != nil { + t.Fatal(err) + } + // ApplyEventStream attaches this same runtime enforcer to the broker. Test + // the public verifier here so the test does not need to expose broker + // internals merely to demonstrate the mandatory local ceiling. + verifier := eventstream.DecisionEventVerifier{ + Enforcer: runtime.enforcer, + Resource: func(_ coreapi.Addr, event *eventstream.Event) string { + return strings.Replace(runtime.eventTemplate, "{topic}", event.Topic, 1) + }, + } + event := &eventstream.Event{Topic: "finance.orders", Payload: []byte("approved")} + missing := signedEventForControl(t, fixture, nil, event, 1) + if err := verifier.VerifyGovernedEvent(context.Background(), coreapi.Addr{}, missing); err == nil || !strings.Contains(err.Error(), "mandate is required") { + t.Fatalf("missing event mandate error=%v", err) + } + allowed := signedEventForControl(t, fixture, &mandate, event, 1) + if err := verifier.VerifyGovernedEvent(context.Background(), coreapi.Addr{}, allowed); err != nil { + t.Fatalf("valid mandated event rejected: %v", err) + } +} + +func TestMandateBundleRefreshAtomicallyRevokesAndRejectsRollback(t *testing.T) { + fixture := newControlFixture(t) + directory := filepath.Dir(fixture.path) + mandate := decision.Mandate{ + Version: decision.SchemaVersion, ID: "mandate-refresh-1", TenantID: "tenant-a", SubjectAgentID: "sender-a", + Actions: []string{"data.send.text"}, ResourcePrefixes: []string{"agent:receiver/inbox"}, Audience: "agent:receiver", Purpose: "inbox-delivery", + RevocationEpoch: 1, IssuedAt: fixture.now.Add(-time.Minute).Unix(), ExpiresAt: fixture.now.Add(time.Hour).Unix(), KeyID: "authority-key", + } + if err := mandate.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + bootstrap := decision.MandateBundle{ + Version: decision.SchemaVersion, TenantID: "tenant-a", SubjectAgentID: "sender-a", Revision: 1, RevocationEpoch: 1, + Mandates: []decision.Mandate{mandate}, IssuedAt: fixture.now.Add(-time.Minute).Unix(), ExpiresAt: fixture.now.Add(time.Hour).Unix(), KeyID: "authority-key", + } + if err := bootstrap.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + removal := decision.MandateBundle{ + Version: decision.SchemaVersion, TenantID: "tenant-a", SubjectAgentID: "sender-a", Revision: 2, RevocationEpoch: 1, + IssuedAt: fixture.now.Add(-time.Minute).Unix(), ExpiresAt: fixture.now.Add(time.Hour).Unix(), KeyID: "authority-key", + } + if err := removal.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + remoteBundle := removal + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v1/trust-current": + trust, err := readSecureJSON[authority.TrustBundle](filepath.Join(directory, "trust.json")) + if err != nil { + t.Errorf("read trust: %v", err) + writer.WriteHeader(http.StatusInternalServerError) + return + } + _ = json.NewEncoder(writer).Encode(trust) + case "/v1/policy-candidate", "/v1/policy-current": + writer.WriteHeader(http.StatusNoContent) + case "/v1/mandates-current": + if request.URL.Query().Get("tenant_id") != "tenant-a" || request.URL.Query().Get("agent_id") != "sender-a" { + writer.WriteHeader(http.StatusBadRequest) + return + } + _ = json.NewEncoder(writer).Encode(remoteBundle) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + seed := base64.StdEncoding.EncodeToString(fixture.intentPrivate.Seed()) + if err := os.WriteFile(filepath.Join(directory, "ack.seed"), []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + writeControlJSON(t, filepath.Join(directory, "mandate.bundle.json"), bootstrap) + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.Mandates = &MandateConfig{BundlePath: "mandate.bundle.json", AgentID: "sender-a"} + config.Rollout = &RolloutConfig{AuthorityEndpoint: server.URL, AgentID: "sender-a", AcknowledgementKeyID: "sender-key", AcknowledgementSeedPath: "ack.seed"} + writeControlJSON(t, fixture.path, config) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + dataConfig := dataexchange.ServiceConfig{} + if err := runtime.ApplyDataExchange(&dataConfig); err != nil { + t.Fatal(err) + } + allowed := signedMandatedFrameForControl(t, fixture, mandate, &dataexchange.Frame{Type: dataexchange.TypeText, Payload: []byte("before revocation")}, 1) + if err := dataConfig.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, allowed); err != nil { + t.Fatalf("bootstrap mandate rejected: %v", err) + } + if err := runtime.RefreshRollout(context.Background()); err != nil { + t.Fatalf("refresh remote removal: %v", err) + } + if err := dataConfig.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, allowed); err == nil { + t.Fatal("removed mandate still authorized data exchange") + } + state, err := readSecureJSON[controlState](filepath.Join(directory, ".enterprise-control-state.json")) + if err != nil || state.MandateRevision != removal.Revision { + t.Fatalf("mandate floor=%+v err=%v", state, err) + } + remoteBundle = bootstrap + if err := runtime.RefreshRollout(context.Background()); err == nil || !strings.Contains(err.Error(), "rollback floor") { + t.Fatalf("rollback bundle error=%v", err) + } + restarted, err := Load(fixture.path) + if err != nil { + t.Fatalf("restart after removal: %v", err) + } + restartedData := dataexchange.ServiceConfig{} + if err := restarted.ApplyDataExchange(&restartedData); err != nil { + t.Fatal(err) + } + if err := restartedData.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, allowed); err == nil { + t.Fatal("restart restored a revoked mandate") + } +} + +func TestOutboundDecisionCreatesFreshSignedIntentAndVerifiesAuthorityResponse(t *testing.T) { + fixture := newControlFixture(t) + directory := filepath.Dir(fixture.path) + seedPath := filepath.Join(directory, "sender-intent.seed") + if err := os.WriteFile(seedPath, []byte(base64.StdEncoding.EncodeToString(fixture.intentPrivate.Seed())), 0o600); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != "/v1/authorize" { + writer.WriteHeader(http.StatusNotFound) + return + } + var intent decision.Intent + if err := json.NewDecoder(request.Body).Decode(&intent); err != nil { + t.Errorf("decode intent: %v", err) + writer.WriteHeader(http.StatusBadRequest) + return + } + if err := intent.Verify(fixture.intentPrivate.Public().(ed25519.PublicKey), time.Now()); err != nil { + t.Errorf("intent signature: %v", err) + writer.WriteHeader(http.StatusUnauthorized) + return + } + intentHash, err := intent.Hash() + if err != nil { + t.Errorf("hash intent: %v", err) + writer.WriteHeader(http.StatusBadRequest) + return + } + result := decision.Decision{ + Version: decision.SchemaVersion, ID: "decision-outbound-1", IntentHash: intentHash, + TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: decision.Allow, + PolicyRevision: 1, RevocationEpoch: 1, ProviderID: "self-hosted", IssuedAt: time.Now().Unix(), ExpiresAt: intent.ExpiresAt, KeyID: "authority-key", + } + if err := result.Sign(fixture.decisionPrivate); err != nil { + t.Errorf("sign decision: %v", err) + writer.WriteHeader(http.StatusInternalServerError) + return + } + _ = json.NewEncoder(writer).Encode(result) + })) + defer server.Close() + configureOutboundDecision(t, fixture, server.URL, filepath.Base(seedPath), decision.RiskHigh) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + frame := &dataexchange.Frame{Type: dataexchange.TypeText, Payload: []byte("approved")} + intent, result, err := runtime.AuthorizeOutbound(context.Background(), "data.send.text", "agent:receiver/inbox", dataexchange.GovernedPayloadHash(frame.Type, frame.Filename, frame.Payload)) + if err != nil { + t.Fatalf("authorize outbound: %v", err) + } + if intent.AgentID != "sender-a" || intent.KeyID != "sender-key" || intent.Risk != decision.RiskHigh || result.Outcome != decision.Allow { + t.Fatalf("intent=%+v decision=%+v", intent, result) + } +} + +func TestOutboundDisclosureUsesTypedAuthorityEnvelope(t *testing.T) { + fixture := newControlFixture(t) + mandate := configureControlMandate(t, fixture) + directory := filepath.Dir(fixture.path) + seedPath := filepath.Join(directory, "sender-intent.seed") + if err := os.WriteFile(seedPath, []byte(base64.StdEncoding.EncodeToString(fixture.intentPrivate.Seed())), 0o600); err != nil { + t.Fatal(err) + } + disclosure := decision.DisclosureBinding{ + Version: decision.DisclosureBindingVersion, ContentHash: decision.HashPayload([]byte("approved")), DeclaredBytes: 8, + ContentType: "text/plain", Labels: []string{"confidential", "pii"}, Recipient: mandate.Audience, + Purpose: mandate.Purpose, Residency: "eu-west-1", + } + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + var received struct { + Intent decision.Intent `json:"intent"` + Disclosure *decision.DisclosureBinding `json:"disclosure"` + } + if err := json.NewDecoder(request.Body).Decode(&received); err != nil || received.Disclosure == nil { + t.Errorf("decode disclosure envelope: %v received=%+v", err, received) + writer.WriteHeader(http.StatusBadRequest) + return + } + if err := received.Disclosure.VerifyIntent(received.Intent); err != nil || received.Disclosure.Residency != disclosure.Residency { + t.Errorf("disclosure binding: %v received=%+v", err, received) + writer.WriteHeader(http.StatusBadRequest) + return + } + intentHash, _ := received.Intent.Hash() + result := decision.Decision{ + Version: decision.SchemaVersion, ID: "decision-outbound-disclosure", IntentHash: intentHash, + TenantID: received.Intent.TenantID, AgentID: received.Intent.AgentID, Outcome: decision.Allow, + PolicyRevision: 1, RevocationEpoch: 1, ProviderID: "self-hosted", IssuedAt: time.Now().Unix(), ExpiresAt: received.Intent.ExpiresAt, KeyID: "authority-key", + } + if err := result.Sign(fixture.decisionPrivate); err != nil { + t.Errorf("sign decision: %v", err) + writer.WriteHeader(http.StatusInternalServerError) + return + } + _ = json.NewEncoder(writer).Encode(result) + })) + defer server.Close() + configureOutboundDecision(t, fixture, server.URL, filepath.Base(seedPath), decision.RiskHigh) + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.OutboundDecisions.MandateID = mandate.ID + config.OutboundDecisions.Audience = disclosure.Recipient + config.OutboundDecisions.Purpose = disclosure.Purpose + config.OutboundDecisions.EvaluatorResidency = disclosure.Residency + writeControlJSON(t, fixture.path, config) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + intent, result, err := runtime.AuthorizeOutboundDisclosure(context.Background(), "data.send.text", "agent:receiver/inbox", disclosure) + if err != nil { + t.Fatal(err) + } + if intent.PayloadHash == decision.HashPayload([]byte("approved")) || result.Outcome != decision.Allow { + t.Fatalf("outbound disclosure intent=%+v result=%+v", intent, result) + } + wrongRecipient := disclosure + wrongRecipient.Recipient = "agent:other" + if _, _, err := runtime.AuthorizeOutboundDisclosure(context.Background(), "data.send.text", "agent:receiver/inbox", wrongRecipient); err == nil || !strings.Contains(err.Error(), "must match attachment") { + t.Fatalf("recipient mismatch accepted: %v", err) + } + wrongResidency := disclosure + wrongResidency.Residency = "us-east-1" + if _, _, err := runtime.AuthorizeOutboundDisclosure(context.Background(), "data.send.text", "agent:receiver/inbox", wrongResidency); err == nil || !strings.Contains(err.Error(), "does not match configured evaluator") { + t.Fatalf("evaluator residency mismatch accepted: %v", err) + } +} + +func TestOutboundDisclosureVerifiesIndependentEvaluatorAttestation(t *testing.T) { + fixture := newControlFixture(t) + mandate := configureControlMandate(t, fixture) + directory := filepath.Dir(fixture.path) + seedPath := filepath.Join(directory, "sender-intent.seed") + if err := os.WriteFile(seedPath, []byte(base64.StdEncoding.EncodeToString(fixture.intentPrivate.Seed())), 0o600); err != nil { + t.Fatal(err) + } + attestorPublic, attestorPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + disclosure := decision.DisclosureBinding{ + Version: decision.DisclosureBindingVersion, ContentHash: decision.HashPayload([]byte("approved")), DeclaredBytes: 8, + ContentType: "text/plain", Labels: []string{"confidential", "pii"}, Recipient: mandate.Audience, + Purpose: mandate.Purpose, Residency: "eu-west-1", + } + var attestationRequests atomic.Int32 + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == decisionhttp.EvaluatorAttestationPath { + attestationRequests.Add(1) + now := time.Now().UTC().Truncate(time.Second) + attestation := decision.EvaluatorAttestation{ + Version: decision.EvaluatorAttestationVersion, Endpoint: server.URL, Residency: disclosure.Residency, + AttestorID: "regional-attestor", EvidenceHash: strings.Repeat("a", 64), IssuedAt: now.Unix(), ExpiresAt: now.Add(5 * time.Minute).Unix(), KeyID: "region-key-1", + } + if err := attestation.Sign(attestorPrivate); err != nil { + t.Errorf("sign evaluator attestation: %v", err) + writer.WriteHeader(http.StatusInternalServerError) + return + } + _ = json.NewEncoder(writer).Encode(attestation) + return + } + if request.URL.Path != "/v1/authorize" { + writer.WriteHeader(http.StatusNotFound) + return + } + var received struct { + Intent decision.Intent `json:"intent"` + Disclosure *decision.DisclosureBinding `json:"disclosure"` + } + if err := json.NewDecoder(request.Body).Decode(&received); err != nil || received.Disclosure == nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + intentHash, _ := received.Intent.Hash() + result := decision.Decision{ + Version: decision.SchemaVersion, ID: "decision-outbound-attested", IntentHash: intentHash, + TenantID: received.Intent.TenantID, AgentID: received.Intent.AgentID, Outcome: decision.Allow, + PolicyRevision: 1, RevocationEpoch: 1, ProviderID: "self-hosted", IssuedAt: time.Now().Unix(), ExpiresAt: received.Intent.ExpiresAt, KeyID: "authority-key", + } + if err := result.Sign(fixture.decisionPrivate); err != nil { + t.Errorf("sign decision: %v", err) + writer.WriteHeader(http.StatusInternalServerError) + return + } + _ = json.NewEncoder(writer).Encode(result) + })) + defer server.Close() + configureOutboundDecision(t, fixture, server.URL, filepath.Base(seedPath), decision.RiskHigh) + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.OutboundDecisions.MandateID = mandate.ID + config.OutboundDecisions.Audience = disclosure.Recipient + config.OutboundDecisions.Purpose = disclosure.Purpose + config.OutboundDecisions.EvaluatorResidency = disclosure.Residency + config.OutboundDecisions.EvaluatorAttestation = &EvaluatorAttestationConfig{ + AttestorID: "regional-attestor", KeyID: "region-key-1", PublicKey: base64.StdEncoding.EncodeToString(attestorPublic), + } + writeControlJSON(t, fixture.path, config) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + for attempt := 0; attempt < 2; attempt++ { + if _, result, err := runtime.AuthorizeOutboundDisclosure(context.Background(), "data.send.text", "agent:receiver/inbox", disclosure); err != nil || result.Outcome != decision.Allow { + t.Fatalf("attested outbound authorization attempt=%d result=%+v err=%v", attempt, result, err) + } + } + if requests := attestationRequests.Load(); requests != 1 { + t.Fatalf("attestation fetches=%d want 1 cached short-lived assertion", requests) + } +} + +func TestOutboundDecisionReturnsSignedDenyWithoutAuthorizingSideEffect(t *testing.T) { + fixture := newControlFixture(t) + directory := filepath.Dir(fixture.path) + seedPath := filepath.Join(directory, "sender-intent.seed") + if err := os.WriteFile(seedPath, []byte(base64.StdEncoding.EncodeToString(fixture.intentPrivate.Seed())), 0o600); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + var intent decision.Intent + if err := json.NewDecoder(request.Body).Decode(&intent); err != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + intentHash, _ := intent.Hash() + result := decision.Decision{ + Version: decision.SchemaVersion, ID: "decision-outbound-deny", IntentHash: intentHash, + TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: decision.Deny, Reasons: []string{"policy:blocked"}, + PolicyRevision: 1, RevocationEpoch: 1, ProviderID: "self-hosted", IssuedAt: time.Now().Unix(), ExpiresAt: intent.ExpiresAt, KeyID: "authority-key", + } + if err := result.Sign(fixture.decisionPrivate); err != nil { + t.Errorf("sign decision: %v", err) + writer.WriteHeader(http.StatusInternalServerError) + return + } + _ = json.NewEncoder(writer).Encode(result) + })) + defer server.Close() + configureOutboundDecision(t, fixture, server.URL, filepath.Base(seedPath), decision.RiskHigh) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + _, result, err := runtime.AuthorizeOutbound(context.Background(), "data.send.text", "agent:receiver/inbox", decision.HashPayload([]byte("blocked"))) + if err != nil { + t.Fatalf("verify deny: %v", err) + } + if result.Outcome != decision.Deny || len(result.Reasons) != 1 { + t.Fatalf("deny=%+v", result) + } +} + +func TestOutboundDecisionCarriesAttachmentBoundMandate(t *testing.T) { + fixture := newControlFixture(t) + mandate := configureControlMandate(t, fixture) + directory := filepath.Dir(fixture.path) + seedPath := filepath.Join(directory, "sender-intent.seed") + if err := os.WriteFile(seedPath, []byte(base64.StdEncoding.EncodeToString(fixture.intentPrivate.Seed())), 0o600); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + var intent decision.Intent + if err := json.NewDecoder(request.Body).Decode(&intent); err != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + if intent.MandateID != mandate.ID || intent.Audience != mandate.Audience || intent.Purpose != mandate.Purpose { + t.Errorf("delegated intent=%+v", intent) + writer.WriteHeader(http.StatusBadRequest) + return + } + intentHash, _ := intent.Hash() + result := decision.Decision{ + Version: decision.SchemaVersion, ID: "decision-outbound-mandate", IntentHash: intentHash, TenantID: intent.TenantID, AgentID: intent.AgentID, + Outcome: decision.Allow, PolicyRevision: 1, RevocationEpoch: 1, ProviderID: "self-hosted", IssuedAt: time.Now().Unix(), ExpiresAt: intent.ExpiresAt, KeyID: "authority-key", + } + if err := result.Sign(fixture.decisionPrivate); err != nil { + t.Errorf("sign decision: %v", err) + writer.WriteHeader(http.StatusInternalServerError) + return + } + _ = json.NewEncoder(writer).Encode(result) + })) + defer server.Close() + configureOutboundDecision(t, fixture, server.URL, filepath.Base(seedPath), decision.RiskHigh) + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.OutboundDecisions.MandateID = mandate.ID + config.OutboundDecisions.Audience = mandate.Audience + config.OutboundDecisions.Purpose = mandate.Purpose + writeControlJSON(t, fixture.path, config) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + intent, result, err := runtime.AuthorizeOutbound(context.Background(), "data.send.text", "agent:receiver/inbox", decision.HashPayload([]byte("delegated message"))) + if err != nil { + t.Fatal(err) + } + if intent.MandateID != mandate.ID || result.Outcome != decision.Allow { + t.Fatalf("intent=%+v result=%+v", intent, result) + } +} + +func configureOutboundDecision(t *testing.T, fixture controlFixture, endpoint, seedPath string, risk decision.RiskClass) { + t.Helper() + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.OutboundDecisions = &OutboundDecisionConfig{ + AuthorityEndpoint: endpoint, AgentID: "sender-a", IntentKeyID: "sender-key", IntentSeedPath: seedPath, Risk: risk, + } + writeControlJSON(t, fixture.path, config) +} + +func TestControlStateRejectsTrustAndPolicyRollback(t *testing.T) { + state := controlState{TenantID: "tenant-a", TrustRevision: 4, TrustPolicyRevision: 6, TrustRevocationEpoch: 3, PolicyRevision: 8, PolicyRevocationEpoch: 3} + trust := authority.TrustBundle{Revision: 4, PolicyRevision: 6, RevocationEpoch: 3} + policy := authority.PolicyBundle{Revision: 8, RevocationEpoch: 3} + if err := state.accepts("tenant-a", trust, policy); err != nil { + t.Fatalf("current state rejected: %v", err) + } + trust.Revision = 3 + if err := state.accepts("tenant-a", trust, policy); err == nil || !strings.Contains(err.Error(), "rollback floor") { + t.Fatalf("trust rollback error = %v", err) + } + trust.Revision = 4 + policy.Revision = 7 + if err := state.accepts("tenant-a", trust, policy); err == nil || !strings.Contains(err.Error(), "rollback floor") { + t.Fatalf("policy rollback error = %v", err) + } +} + +func TestControlStateRejectsMandateBundleEpochRollback(t *testing.T) { + state := controlState{TenantID: "tenant-a", MandateRevision: 2, MandateRevocationEpoch: 3, MandateHash: strings.Repeat("a", 64)} + bundle := decision.MandateBundle{TenantID: "tenant-a", SubjectAgentID: "sender-a", Revision: 3, RevocationEpoch: 2} + if err := state.acceptsMandate(bundle, strings.Repeat("b", 64)); err == nil || !strings.Contains(err.Error(), "rollback floor") { + t.Fatalf("mandate epoch rollback error=%v", err) + } +} + +func TestRequireEnabledServiceGatesRejectsOmittedOrPermissiveRules(t *testing.T) { + if err := (*Runtime)(nil).RequireEnabledServiceGates(true, false); err == nil || !strings.Contains(err.Error(), "data exchange") { + t.Fatalf("nil runtime error = %v, want data-exchange gate failure", err) + } + runtime := &Runtime{dataEnabled: true, dataRequired: true, eventEnabled: true, eventRequired: false, receipts: &governedReceiptSigner{}} + if err := runtime.RequireEnabledServiceGates(true, true); err == nil || !strings.Contains(err.Error(), "event stream") { + t.Fatalf("permissive event rule error = %v, want event-stream gate failure", err) + } + if err := runtime.RequireEnabledServiceGates(true, false); err != nil { + t.Fatalf("required enabled data exchange rejected: %v", err) + } +} + +func TestReceiptAttachmentSignsDurableGovernedTransportEvidence(t *testing.T) { + fixture := newControlFixture(t) + directory := filepath.Dir(fixture.path) + var exported int + exportServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + var receipt decision.Receipt + if err := json.NewDecoder(request.Body).Decode(&receipt); err != nil || request.Header.Get("Idempotency-Key") != receipt.ID { + writer.WriteHeader(http.StatusBadRequest) + return + } + exported++ + _ = json.NewEncoder(writer).Encode(map[string]string{"accepted_receipt_id": receipt.ID}) + })) + defer exportServer.Close() + seed := base64.StdEncoding.EncodeToString(fixture.intentPrivate.Seed()) + if err := os.WriteFile(filepath.Join(directory, "receipt.seed"), []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.Receipts = &ReceiptConfig{ + AgentID: "receiver-a", KeyID: "receiver-receipt-key", SeedPath: "receipt.seed", JournalPath: "receipts.jsonl", + ExportEndpoint: exportServer.URL, ExportAcknowledgementPath: "receipt-export.acks", + } + writeControlJSON(t, fixture.path, config) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatalf("load receipt attachment: %v", err) + } + dataConfig := dataexchange.ServiceConfig{} + if err := runtime.ApplyDataExchange(&dataConfig); err != nil { + t.Fatal(err) + } + if !dataConfig.RequireGovernedReceipts || dataConfig.GovernedReceiptRecorder == nil { + t.Fatal("enterprise data exchange was not configured with a required receipt recorder") + } + governed := signedFrameForControl(t, fixture, &dataexchange.Frame{Type: dataexchange.TypeText, Payload: []byte("receipted")}, 1) + if err := dataConfig.GovernedReceiptRecorder.RecordGovernedReceipt(context.Background(), governed.Intent, governed.Decision); err != nil { + t.Fatalf("record governed receipt: %v", err) + } + if !runtime.HasReceiptExport() || runtime.ReceiptExportInterval() != 30*time.Second { + t.Fatalf("receipt export configuration was not retained") + } + if err := runtime.ExportReceiptsOnce(context.Background()); err != nil || exported != 1 { + t.Fatalf("export governed receipt err=%v exported=%d", err, exported) + } + contents, err := os.ReadFile(filepath.Join(directory, "receipts.jsonl")) + if err != nil { + t.Fatal(err) + } + var receipt decision.Receipt + if err := json.Unmarshal(contents, &receipt); err != nil { + t.Fatalf("decode receipt: %v", err) + } + if receipt.EnforcementPoint != "dataexchange" || receipt.KeyID != "receiver-receipt-key" || receipt.Result != decision.Enforced { + t.Fatalf("receipt = %+v", receipt) + } + if err := receipt.VerifyForEnforcer(governed.Intent, governed.Decision, "receiver-a", fixture.intentPrivate.Public().(ed25519.PublicKey)); err != nil { + t.Fatalf("verify governed receipt: %v", err) + } + disclosure := decision.DisclosureBinding{ + Version: decision.DisclosureBindingVersion, ContentHash: decision.HashPayload([]byte("classified")), DeclaredBytes: 10, + ContentType: "text/plain", Labels: []string{"confidential", "pii"}, Recipient: "agent:receiver", Purpose: "inbox-delivery", Residency: "eu-west-1", + } + disclosureHash, err := disclosure.Hash() + if err != nil { + t.Fatal(err) + } + nonce, err := decision.NewNonce() + if err != nil { + t.Fatal(err) + } + disclosureIntent := decision.Intent{ + Version: decision.SchemaVersion, ID: "disclosure-receipt-intent", TenantID: "tenant-a", AgentID: "sender-a", Action: "data.send.text", Resource: "agent:receiver/inbox", + Audience: disclosure.Recipient, Purpose: disclosure.Purpose, PayloadHash: disclosureHash, Risk: decision.RiskHigh, + IssuedAt: fixture.now.Unix(), ExpiresAt: fixture.now.Add(time.Minute).Unix(), Nonce: nonce, KeyID: "sender-key", + } + if err := disclosureIntent.Sign(fixture.intentPrivate); err != nil { + t.Fatal(err) + } + intentHash, err := disclosureIntent.Hash() + if err != nil { + t.Fatal(err) + } + disclosureDecision := decision.Decision{ + Version: decision.SchemaVersion, ID: "disclosure-receipt-decision", IntentHash: intentHash, TenantID: "tenant-a", AgentID: "sender-a", Outcome: decision.Allow, + PolicyRevision: 1, RevocationEpoch: 1, ProviderID: "authority-a", IssuedAt: fixture.now.Unix(), ExpiresAt: fixture.now.Add(time.Minute).Unix(), KeyID: "authority-key", + } + if err := disclosureDecision.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + typedRecorder, supported := dataConfig.GovernedReceiptRecorder.(dataexchange.GovernedDisclosureReceiptRecorder) + if !supported { + t.Fatal("enterprise receipt recorder does not support V2 disclosure evidence") + } + if err := typedRecorder.RecordGovernedDisclosureReceipt(context.Background(), disclosureIntent, disclosureDecision, disclosure); err != nil { + t.Fatalf("record V2 disclosure receipt: %v", err) + } + receipts := runtime.receipts.journal.Receipts() + typedReceipt := receipts[len(receipts)-1] + if err := typedReceipt.VerifyForDisclosure(disclosureIntent, disclosureDecision, disclosure, "receiver-a", fixture.intentPrivate.Public().(ed25519.PublicKey)); err != nil { + t.Fatalf("verify V2 governed receipt: %v", err) + } + rotated := trustRevisionForControl(t, fixture, 2, 1, 2) + rotated.Keys = []authority.AuthorityKey{rotated.Keys[0], rotated.Keys[2]} + rotated.Signature = "" + if err := rotated.Sign(fixture.rootPrivate); err != nil { + t.Fatal(err) + } + if err := runtime.trust.Install(rotated); err != nil { + t.Fatalf("install receipt-key revocation: %v", err) + } + if err := dataConfig.GovernedReceiptRecorder.RecordGovernedReceipt(context.Background(), governed.Intent, governed.Decision); err == nil || !strings.Contains(err.Error(), "no longer active") { + t.Fatalf("revoked receipt key error = %v", err) + } +} + +func TestRolloutRefreshStagesCandidateThenInstallsOnlyAuthorityActivePolicy(t *testing.T) { + fixture := newControlFixture(t) + directory := filepath.Dir(fixture.path) + rotatedTrust := trustRevisionForControl(t, fixture, 2, 2, 1) + candidatePolicy := rolloutPolicyForControl(t, fixture, 2, true) + candidateHash, err := candidatePolicy.Hash() + if err != nil { + t.Fatal(err) + } + publication := authority.PolicyPublication{ + Version: authority.SchemaVersion, ID: "rollout-2", TenantID: "tenant-a", PolicyRevision: 2, RevocationEpoch: 1, + PolicyHash: candidateHash, ExpectedAgents: []string{"sender-a"}, RequiredAcknowledged: 1, IssuedAt: fixture.now.Unix(), KeyID: "authority-key", + } + if err := publication.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + activation, err := authority.NewPolicyActivation(publication, candidatePolicy, fixture.now, fixture.now, "authority-key") + if err != nil { + t.Fatal(err) + } + if err := activation.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + var ( + candidateAvailable = true + active *authorityhttp.ActivePolicyEnvelope + acknowledgements []authority.PolicyAckStatus + ) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v1/trust-current": + _ = json.NewEncoder(writer).Encode(rotatedTrust) + case "/v1/policy-candidate": + if candidateAvailable { + _ = json.NewEncoder(writer).Encode(authorityhttp.PublicationEnvelope{Publication: publication, Bundle: candidatePolicy}) + return + } + writer.WriteHeader(http.StatusNoContent) + case "/v1/policy-current": + if active == nil { + writer.WriteHeader(http.StatusNoContent) + return + } + _ = json.NewEncoder(writer).Encode(*active) + case "/v1/policy-ack": + var ack authority.PolicyAcknowledgement + if err := json.NewDecoder(request.Body).Decode(&ack); err != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + if err := ack.Verify(fixture.intentPrivate.Public().(ed25519.PublicKey), time.Now()); err != nil { + t.Errorf("ack verification: %v", err) + writer.WriteHeader(http.StatusBadRequest) + return + } + acknowledgements = append(acknowledgements, ack.Status) + _ = json.NewEncoder(writer).Encode(authority.RolloutStatus{Publication: publication, Ready: true}) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + seed := base64.StdEncoding.EncodeToString(fixture.intentPrivate.Seed()) + if err := os.WriteFile(filepath.Join(directory, "ack.seed"), []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.Rollout = &RolloutConfig{AuthorityEndpoint: server.URL, AgentID: "sender-a", AcknowledgementKeyID: "sender-key", AcknowledgementSeedPath: "ack.seed"} + writeControlJSON(t, fixture.path, config) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + dataConfig := dataexchange.ServiceConfig{} + if err := runtime.ApplyDataExchange(&dataConfig); err != nil { + t.Fatal(err) + } + if err := runtime.RefreshRollout(context.Background()); err != nil { + t.Fatalf("stage candidate: %v", err) + } + if len(acknowledgements) != 1 || acknowledgements[0] != authority.PolicyAckStaged { + t.Fatalf("acknowledgements after stage = %v", acknowledgements) + } + denied := signedFrameForControl(t, fixture, &dataexchange.Frame{Type: dataexchange.TypeBinary, Payload: []byte("not active")}, 2) + if err := dataConfig.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, denied); err == nil || !strings.Contains(err.Error(), "local authority ceiling") { + t.Fatalf("staged policy was enforced before authority activation: %v", err) + } + candidateAvailable = false + active = &authorityhttp.ActivePolicyEnvelope{Publication: publication, Bundle: candidatePolicy, Activation: activation} + if err := runtime.RefreshRollout(context.Background()); err != nil { + t.Fatalf("install active policy: %v", err) + } + if len(acknowledgements) != 1 || acknowledgements[0] != authority.PolicyAckStaged { + t.Fatalf("activation changed staged acknowledgement evidence = %v", acknowledgements) + } + allowed := signedFrameForControl(t, fixture, &dataexchange.Frame{Type: dataexchange.TypeBinary, Payload: []byte("active")}, 2) + if err := dataConfig.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, allowed); err != nil { + t.Fatalf("authority-active policy was not enforced: %v", err) + } + // Remote trust and the authority-active policy are written back to the + // protected attachment files. A restart therefore restores the same + // ceiling rather than regressing to the original bootstrap state. + restarted, err := Load(fixture.path) + if err != nil { + t.Fatalf("restart with persisted rollout state: %v", err) + } + restartedData := dataexchange.ServiceConfig{} + if err := restarted.ApplyDataExchange(&restartedData); err != nil { + t.Fatal(err) + } + if err := restartedData.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, allowed); err != nil { + t.Fatalf("persisted authority-active policy was not restored: %v", err) + } +} + +func TestFleetControlReportsBoundedStatusAndVerifiesCommands(t *testing.T) { + fixture := newControlFixture(t) + directory := filepath.Dir(fixture.path) + command := authority.FleetCommand{ + Version: authority.FleetCommandVersion, ID: "fleet-command-1", TenantID: "tenant-a", Targets: []string{"sender-a"}, + Kind: authority.FleetCommandRefreshPolicy, IssuedAt: fixture.now.Unix(), ExpiresAt: fixture.now.Add(time.Hour).Unix(), KeyID: "authority-key", + } + if err := command.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + var reports []authority.FleetNodeReport + var results []authority.FleetCommandResult + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v1/fleet/report": + var report authority.FleetNodeReport + if err := json.NewDecoder(request.Body).Decode(&report); err != nil || report.Verify(fixture.intentPrivate.Public().(ed25519.PublicKey), time.Now()) != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + reports = append(reports, report) + case "/v1/fleet/commands": + _ = json.NewEncoder(writer).Encode(map[string]any{"commands": []authority.FleetCommand{command}}) + case "/v1/fleet/result": + var result authority.FleetCommandResult + if err := json.NewDecoder(request.Body).Decode(&result); err != nil || result.Verify(fixture.intentPrivate.Public().(ed25519.PublicKey), time.Now()) != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + results = append(results, result) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + if err := os.WriteFile(filepath.Join(directory, "ack.seed"), []byte(base64.StdEncoding.EncodeToString(fixture.intentPrivate.Seed())), 0o600); err != nil { + t.Fatal(err) + } + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.Rollout = &RolloutConfig{AuthorityEndpoint: server.URL, AgentID: "sender-a", AcknowledgementKeyID: "sender-key", AcknowledgementSeedPath: "ack.seed"} + config.Fleet = &FleetConfig{ReportIntervalSeconds: 30} + writeControlJSON(t, fixture.path, config) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + if !runtime.HasFleetControl() || runtime.FleetReportInterval() != 30*time.Second { + t.Fatalf("fleet control unavailable interval=%s", runtime.FleetReportInterval()) + } + if err := runtime.ReportFleetStatus(context.Background(), FleetNodeStatus{NodeID: 7, AgentVersion: "1.2.3", Connections: 2, BytesSent: 5}); err != nil { + t.Fatal(err) + } + if len(reports) != 1 || reports[0].AgentID != "sender-a" || reports[0].Connections != 2 { + t.Fatalf("reports=%+v", reports) + } + commands, err := runtime.FleetCommands(context.Background()) + if err != nil || len(commands) != 1 || commands[0].ID != command.ID { + t.Fatalf("commands=%+v err=%v", commands, err) + } + if err := runtime.ReportFleetCommandResult(context.Background(), command.ID, "succeeded", ""); err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].CommandID != command.ID || results[0].Outcome != "succeeded" { + t.Fatalf("results=%+v", results) + } +} + +func TestFleetDesiredStateIsVerifiedPersistedAndQuarantinesHookedActions(t *testing.T) { + fixture := newControlFixture(t) + directory := filepath.Dir(fixture.path) + control := authority.FleetNodeControl{ + Version: authority.FleetNodeControlVersion, TenantID: "tenant-a", AgentID: "sender-a", Revision: 7, + Group: "finance", Tags: []string{"production"}, DesiredVersion: "1.2.3", DesiredPolicyRevision: 1, + Quarantined: true, Reason: "Contain node during incident review", IssuedAt: fixture.now.Unix(), KeyID: "authority-key", + } + if err := control.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + var serveControl atomic.Bool + serveControl.Store(true) + var remoteControl atomic.Value + remoteControl.Store(control) + acknowledgements := make(chan authority.FleetControlAcknowledgement, 2) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/v1/fleet/control-ack" { + var acknowledgement authority.FleetControlAcknowledgement + if err := json.NewDecoder(request.Body).Decode(&acknowledgement); err != nil || acknowledgement.Verify(fixture.intentPrivate.Public().(ed25519.PublicKey), time.Now()) != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + acknowledgements <- acknowledgement + return + } + if request.URL.Path != "/v1/fleet/control" { + writer.WriteHeader(http.StatusNotFound) + return + } + if !serveControl.Load() { + writer.WriteHeader(http.StatusNoContent) + return + } + _ = json.NewEncoder(writer).Encode(remoteControl.Load().(authority.FleetNodeControl)) + })) + defer server.Close() + if err := os.WriteFile(filepath.Join(directory, "ack.seed"), []byte(base64.StdEncoding.EncodeToString(fixture.intentPrivate.Seed())), 0o600); err != nil { + t.Fatal(err) + } + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.Rollout = &RolloutConfig{AuthorityEndpoint: server.URL, AgentID: "sender-a", AcknowledgementKeyID: "sender-key", AcknowledgementSeedPath: "ack.seed"} + config.Fleet = &FleetConfig{ReportIntervalSeconds: 30} + config.ActionControl = &ActionControlConfig{Profile: actionregistry.Profile{Version: actionregistry.SchemaVersion, Mode: actionregistry.ModeLocalEnforce, Actions: []string{"data.send.text"}}, AgentID: "sender-a"} + writeControlJSON(t, fixture.path, config) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + reconciliation, err := runtime.ReconcileFleetControl(context.Background(), "1.2.3") + if err != nil || !reconciliation.Found || reconciliation.Status != "applied" || reconciliation.AppliedPolicyRevision != 1 { + t.Fatalf("reconciliation=%+v err=%v", reconciliation, err) + } + if err := runtime.ReportFleetControlAcknowledgement(context.Background(), reconciliation, "1.2.3"); err != nil { + t.Fatal(err) + } + acknowledgement := <-acknowledgements + if acknowledgement.ControlRevision != control.Revision || acknowledgement.Status != authority.FleetControlApplied || !acknowledgement.Quarantined { + t.Fatalf("acknowledgement=%+v", acknowledgement) + } + installed, found := runtime.CurrentFleetControl() + if !found || installed.Revision != 7 || !installed.Quarantined { + t.Fatalf("installed control=%+v found=%v", installed, found) + } + envelope, err := actionhook.NewEnvelope("trust.request", "agent:peer", actionhook.HashMetadata(map[string]string{"peer_node_id": "42"}), "pilot.handshake", nil, time.Now()) + if err != nil { + t.Fatal(err) + } + preflight, err := runtime.ActionHook().BeforeAction(context.Background(), envelope) + if err != nil || preflight.Outcome != decision.Deny || len(preflight.Reasons) != 1 || preflight.Reasons[0] != "fleet_quarantine" { + t.Fatalf("quarantine preflight=%+v err=%v", preflight, err) + } + + // An unauthenticated absence cannot clear the last signed desired state. + serveControl.Store(false) + reconciliation, err = runtime.ReconcileFleetControl(context.Background(), "1.2.3") + if err != nil || !reconciliation.Found || !reconciliation.Control.Quarantined { + t.Fatalf("absence cleared desired state: %+v err=%v", reconciliation, err) + } + + // The exact signed revision survives restart and remains the rollback floor. + restarted, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + persisted, found := restarted.CurrentFleetControl() + if !found || persisted.Signature != installed.Signature { + t.Fatalf("persisted control=%+v found=%v", persisted, found) + } + serveControl.Store(true) + conflict := control + conflict.Reason = "Conflicting content at the same revision" + if err := conflict.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + remoteControl.Store(conflict) + if _, err := restarted.ReconcileFleetControl(context.Background(), "1.2.3"); err == nil || !strings.Contains(err.Error(), "conflicting desired fleet state") { + t.Fatalf("same-revision conflict error=%v", err) + } +} + +func TestLoadRejectsUnsafeResourceTemplateBeforeReadingBundles(t *testing.T) { + path := filepath.Join(t.TempDir(), "control.json") + writeControlJSON(t, path, Config{ + TenantID: "tenant-a", RootKeyID: "root-key", RootPublicKey: base64.StdEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize)), + TrustBundlePath: "missing-trust.json", PolicyBundlePath: "missing-policy.json", + EventStream: &EventStreamRule{ResourceTemplate: "eventstream:all"}, + }) + if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "exactly one {topic}") { + t.Fatalf("load error = %v, want template rejection", err) + } +} + +func TestLoadRejectsContentInspectionWithoutTypedGovernance(t *testing.T) { + path := filepath.Join(t.TempDir(), "control.json") + base := Config{ + TenantID: "tenant-a", RootKeyID: "root-key", RootPublicKey: base64.StdEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize)), + TrustBundlePath: "missing-trust.json", PolicyBundlePath: "missing-policy.json", + DataExchange: &DataExchangeRule{RequireGoverned: true, RequireContentInspection: true, Resource: "agent:receiver/inbox"}, + } + writeControlJSON(t, path, base) + if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "replaced by Pilot-hosted federation") { + t.Fatalf("data inspection profile error=%v", err) + } + base.DataExchange = nil + base.EventStream = &EventStreamRule{RequireGoverned: true, RequireContentInspection: true, ResourceTemplate: "eventstream:{topic}"} + writeControlJSON(t, path, base) + if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "replaced by Pilot-hosted federation") { + t.Fatalf("event inspection profile error=%v", err) + } +} + +func TestLocalContentInspectionAttachmentIsRejected(t *testing.T) { + fixture := newControlFixture(t) + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.DataExchange = &DataExchangeRule{RequireGoverned: true, RequireDisclosure: true, Resource: "agent:receiver/inbox"} + config.ContentInspection = &ContentInspectionConfig{PresidioEndpoint: "http://127.0.0.1:55002", Entities: []string{"EMAIL_ADDRESS"}, ProcessingResidency: "eu-west-1"} + writeControlJSON(t, fixture.path, config) + if _, err := Load(fixture.path); err == nil || !strings.Contains(err.Error(), "content_inspection is no longer supported") { + t.Fatalf("local inspector attachment error=%v", err) + } +} + +func TestTransferQuotaAttachmentUsesGovernedDataExchange(t *testing.T) { + fixture := newControlFixture(t) + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.DataExchange = &DataExchangeRule{ + RequireGoverned: true, Resource: "agent:receiver/inbox", + TransferQuota: &TransferQuotaConfig{WindowSeconds: 60, MaxBytes: 5, MaxSenders: 2}, + } + writeControlJSON(t, fixture.path, config) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + dataConfig := dataexchange.ServiceConfig{} + if err := runtime.ApplyDataExchange(&dataConfig); err != nil { + t.Fatal(err) + } + if !dataConfig.RequireGoverned || dataConfig.GovernedTransferQuota == nil { + t.Fatalf("data-exchange quota gate = %+v", dataConfig) + } + if err := dataConfig.GovernedTransferQuota.Allow("sender-a", 5); err != nil { + t.Fatal(err) + } + if err := dataConfig.GovernedTransferQuota.Allow("sender-a", 1); err == nil || !strings.Contains(err.Error(), "byte limit") { + t.Fatalf("quota limit error=%v", err) + } + + config.DataExchange = &DataExchangeRule{Resource: "agent:receiver/inbox", TransferQuota: &TransferQuotaConfig{WindowSeconds: 60, MaxBytes: 5, MaxSenders: 2}} + writeControlJSON(t, fixture.path, config) + if _, err := Load(fixture.path); err == nil || !strings.Contains(err.Error(), "transfer_quota requires require_governed") { + t.Fatalf("ungoverned quota attachment error=%v", err) + } +} + +func TestRetentionAttachmentRequiresTypedGovernanceAndConfiguresExpiry(t *testing.T) { + fixture := newControlFixture(t) + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + retention := &DataRetentionConfig{Classes: []DataRetentionClass{{Class: "finance-7y", RetainForSeconds: 3600}}, SweepIntervalSeconds: 60} + config.DataExchange = &DataExchangeRule{RequireGoverned: true, RequireDisclosure: true, Retention: retention, Resource: "agent:receiver/inbox"} + writeControlJSON(t, fixture.path, config) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + dataConfig := dataexchange.ServiceConfig{} + if err := runtime.ApplyDataExchange(&dataConfig); err != nil { + t.Fatal(err) + } + if len(dataConfig.GovernedRetentionPolicies) != 1 || dataConfig.GovernedRetentionPolicies[0].Class != "finance-7y" || dataConfig.GovernedRetentionPolicies[0].RetainFor != time.Hour || dataConfig.RetentionSweepInterval != time.Minute { + t.Fatalf("retention attachment = %+v interval=%s", dataConfig.GovernedRetentionPolicies, dataConfig.RetentionSweepInterval) + } + config.DataExchange = &DataExchangeRule{RequireGoverned: true, Retention: retention, Resource: "agent:receiver/inbox"} + writeControlJSON(t, fixture.path, config) + if _, err := Load(fixture.path); err == nil || !strings.Contains(err.Error(), "retention requires require_governed and require_disclosure") { + t.Fatalf("untyped retention attachment error=%v", err) + } +} + +func TestLoadRejectsBundlePathEscapingControlDirectory(t *testing.T) { + fixture := newControlFixture(t) + config := Config{ + TenantID: "tenant-a", RootKeyID: "root-key", RootPublicKey: base64.StdEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize)), TrustBundlePath: "../trust.json", PolicyBundlePath: "policy.json", + DataExchange: &DataExchangeRule{Resource: "agent:receiver/inbox"}, + } + writeControlJSON(t, fixture.path, config) + if _, err := Load(fixture.path); err == nil || !strings.Contains(err.Error(), "escapes control configuration directory") { + t.Fatalf("load error = %v, want path escape rejection", err) + } +} + +func writeControlRevision(t *testing.T, fixture controlFixture, revision uint64, allowBinary bool) { + t.Helper() + trust := trustRevisionForControl(t, fixture, revision, revision, 1) + rules := []decisionpolicy.Rule{ + {ID: "text-to-inbox", Agents: []string{"*"}, Actions: []string{"data.send.text"}, ResourcePrefixes: []string{"agent:receiver/inbox"}, Risks: allRisks(), Outcome: decision.Allow}, + {ID: "event-publication", Agents: []string{"*"}, Actions: []string{"event.publish"}, ResourcePrefixes: []string{"eventstream:"}, Risks: allRisks(), Outcome: decision.Allow}, + } + if allowBinary { + rules = append(rules, decisionpolicy.Rule{ID: "binary-to-inbox", Agents: []string{"*"}, Actions: []string{"data.send.binary"}, ResourcePrefixes: []string{"agent:receiver/inbox"}, Risks: allRisks(), Outcome: decision.Allow}) + } + payload, err := json.Marshal(decisionpolicy.Document{Version: 1, DefaultOutcome: decision.Deny, Rules: rules}) + if err != nil { + t.Fatalf("marshal policy revision: %v", err) + } + policy := authority.NewPolicyBundle("tenant-a", revision, 1, fixture.now.Add(-time.Minute), fixture.now.Add(-time.Minute), fixture.now.Add(time.Hour), decisionpolicy.Engine, decisionpolicy.EngineVersion, decisionpolicy.ContentType, "authority-key", payload) + if err := policy.Sign(fixture.decisionPrivate); err != nil { + t.Fatalf("sign policy revision: %v", err) + } + directory := filepath.Dir(fixture.path) + writeControlJSON(t, filepath.Join(directory, "trust.json"), trust) + writeControlJSON(t, filepath.Join(directory, "policy.json"), policy) +} + +func trustRevisionForControl(t *testing.T, fixture controlFixture, revision, policyRevision, revocationEpoch uint64) authority.TrustBundle { + t.Helper() + intentPublic := fixture.intentPrivate.Public().(ed25519.PublicKey) + decisionPublic := fixture.decisionPrivate.Public().(ed25519.PublicKey) + trust := authority.TrustBundle{ + Version: authority.SchemaVersion, TenantID: "tenant-a", Revision: revision, PolicyRevision: policyRevision, RevocationEpoch: revocationEpoch, + IssuedAt: fixture.now.Add(-time.Minute).Unix(), ExpiresAt: fixture.now.Add(time.Hour).Unix(), RootKeyID: "root-key", + Keys: []authority.AuthorityKey{ + {KeyID: "sender-key", AgentID: "sender-a", PublicKey: base64.StdEncoding.EncodeToString(intentPublic), Usages: []authority.KeyUsage{authority.UsageIntent}, NotBefore: fixture.now.Add(-time.Minute).Unix(), ExpiresAt: fixture.now.Add(time.Hour).Unix()}, + {KeyID: "receiver-receipt-key", AgentID: "receiver-a", PublicKey: base64.StdEncoding.EncodeToString(intentPublic), Usages: []authority.KeyUsage{authority.UsageReceipt}, NotBefore: fixture.now.Add(-time.Minute).Unix(), ExpiresAt: fixture.now.Add(time.Hour).Unix()}, + {KeyID: "authority-key", PublicKey: base64.StdEncoding.EncodeToString(decisionPublic), Usages: []authority.KeyUsage{authority.UsageDecision, authority.UsagePolicy, authority.UsageMandate}, NotBefore: fixture.now.Add(-time.Minute).Unix(), ExpiresAt: fixture.now.Add(time.Hour).Unix()}, + }, + } + if err := trust.Sign(fixture.rootPrivate); err != nil { + t.Fatalf("sign trust revision: %v", err) + } + return trust +} + +func rolloutPolicyForControl(t *testing.T, fixture controlFixture, revision uint64, allowBinary bool) authority.PolicyBundle { + t.Helper() + rules := []decisionpolicy.Rule{ + {ID: "text-to-inbox", Agents: []string{"*"}, Actions: []string{"data.send.text"}, ResourcePrefixes: []string{"agent:receiver/inbox"}, Risks: allRisks(), Outcome: decision.Allow}, + {ID: "event-publication", Agents: []string{"*"}, Actions: []string{"event.publish"}, ResourcePrefixes: []string{"eventstream:"}, Risks: allRisks(), Outcome: decision.Allow}, + } + if allowBinary { + rules = append(rules, decisionpolicy.Rule{ID: "binary-to-inbox", Agents: []string{"*"}, Actions: []string{"data.send.binary"}, ResourcePrefixes: []string{"agent:receiver/inbox"}, Risks: allRisks(), Outcome: decision.Allow}) + } + payload, err := json.Marshal(decisionpolicy.Document{Version: 1, DefaultOutcome: decision.Deny, Rules: rules}) + if err != nil { + t.Fatal(err) + } + policy := authority.NewPolicyBundle("tenant-a", revision, 1, fixture.now.Add(-time.Minute), fixture.now.Add(-time.Minute), fixture.now.Add(time.Hour), decisionpolicy.Engine, decisionpolicy.EngineVersion, decisionpolicy.ContentType, "authority-key", payload) + if err := policy.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + return policy +} + +func signedFrameForControl(t *testing.T, fixture controlFixture, frame *dataexchange.Frame, revision uint64) dataexchange.GovernedFrame { + t.Helper() + nonce, err := decision.NewNonce() + if err != nil { + t.Fatalf("nonce: %v", err) + } + action := "data.send.text" + if frame.Type == dataexchange.TypeBinary { + action = "data.send.binary" + } + intent := decision.Intent{ + Version: decision.SchemaVersion, ID: "transport-intent", TenantID: "tenant-a", AgentID: "sender-a", Action: action, + Resource: "agent:receiver/inbox", PayloadHash: dataexchange.GovernedPayloadHash(frame.Type, frame.Filename, frame.Payload), Risk: decision.RiskMedium, + IssuedAt: fixture.now.Unix(), ExpiresAt: fixture.now.Add(2 * time.Minute).Unix(), Nonce: nonce, KeyID: "sender-key", + } + if err := intent.Sign(fixture.intentPrivate); err != nil { + t.Fatalf("sign intent: %v", err) + } + intentHash, err := intent.Hash() + if err != nil { + t.Fatalf("hash intent: %v", err) + } + result := decision.Decision{ + Version: decision.SchemaVersion, ID: "transport-decision", IntentHash: intentHash, TenantID: intent.TenantID, AgentID: intent.AgentID, + Outcome: decision.Allow, PolicyRevision: revision, RevocationEpoch: 1, ProviderID: "authority-a", IssuedAt: fixture.now.Unix(), ExpiresAt: fixture.now.Add(time.Minute).Unix(), KeyID: "authority-key", + } + if err := result.Sign(fixture.decisionPrivate); err != nil { + t.Fatalf("sign decision: %v", err) + } + governed, err := dataexchange.NewGovernedFrame(frame, intent, result) + if err != nil { + t.Fatalf("new governed frame: %v", err) + } + return governed +} + +func configureControlMandate(t *testing.T, fixture controlFixture) decision.Mandate { + t.Helper() + mandate := decision.Mandate{ + Version: decision.SchemaVersion, ID: "mandate-inbox-1", TenantID: "tenant-a", SubjectAgentID: "sender-a", + Actions: []string{"data.send.text"}, ResourcePrefixes: []string{"agent:receiver/inbox"}, Audience: "agent:receiver", Purpose: "inbox-delivery", + RevocationEpoch: 1, IssuedAt: fixture.now.Add(-time.Minute).Unix(), ExpiresAt: fixture.now.Add(time.Hour).Unix(), KeyID: "authority-key", + } + if err := mandate.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + directory := filepath.Dir(fixture.path) + writeControlJSON(t, filepath.Join(directory, "mandates.json"), []decision.Mandate{mandate}) + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.Mandates = &MandateConfig{Path: "mandates.json"} + writeControlJSON(t, fixture.path, config) + return mandate +} + +func configureEventControlMandate(t *testing.T, fixture controlFixture) decision.Mandate { + t.Helper() + mandate := decision.Mandate{ + Version: decision.SchemaVersion, ID: "mandate-events-1", TenantID: "tenant-a", SubjectAgentID: "sender-a", + Actions: []string{"event.publish"}, ResourcePrefixes: []string{"eventstream:finance."}, Audience: "broker-a", Purpose: "finance-order-publication", + RevocationEpoch: 1, IssuedAt: fixture.now.Add(-time.Minute).Unix(), ExpiresAt: fixture.now.Add(time.Hour).Unix(), KeyID: "authority-key", + } + if err := mandate.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + directory := filepath.Dir(fixture.path) + writeControlJSON(t, filepath.Join(directory, "mandates.json"), []decision.Mandate{mandate}) + config, err := readSecureJSON[Config](fixture.path) + if err != nil { + t.Fatal(err) + } + config.Mandates = &MandateConfig{Path: "mandates.json"} + writeControlJSON(t, fixture.path, config) + return mandate +} + +func signedMandatedFrameForControl(t *testing.T, fixture controlFixture, mandate decision.Mandate, frame *dataexchange.Frame, revision uint64) dataexchange.GovernedFrame { + t.Helper() + nonce, err := decision.NewNonce() + if err != nil { + t.Fatal(err) + } + intent := decision.Intent{ + Version: decision.SchemaVersion, ID: "transport-mandated-intent", TenantID: "tenant-a", AgentID: "sender-a", Action: "data.send.text", + Resource: "agent:receiver/inbox", MandateID: mandate.ID, Audience: mandate.Audience, Purpose: mandate.Purpose, + PayloadHash: dataexchange.GovernedPayloadHash(frame.Type, frame.Filename, frame.Payload), Risk: decision.RiskMedium, + IssuedAt: fixture.now.Unix(), ExpiresAt: fixture.now.Add(2 * time.Minute).Unix(), Nonce: nonce, KeyID: "sender-key", + } + if err := intent.Sign(fixture.intentPrivate); err != nil { + t.Fatal(err) + } + intentHash, err := intent.Hash() + if err != nil { + t.Fatal(err) + } + result := decision.Decision{ + Version: decision.SchemaVersion, ID: "transport-mandated-decision", IntentHash: intentHash, TenantID: intent.TenantID, AgentID: intent.AgentID, + Outcome: decision.Allow, PolicyRevision: revision, RevocationEpoch: 1, ProviderID: "authority-a", IssuedAt: fixture.now.Unix(), ExpiresAt: fixture.now.Add(time.Minute).Unix(), KeyID: "authority-key", + } + if err := result.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + governed, err := dataexchange.NewGovernedFrame(frame, intent, result) + if err != nil { + t.Fatal(err) + } + return governed +} + +func signedEventForControl(t *testing.T, fixture controlFixture, mandate *decision.Mandate, event *eventstream.Event, revision uint64) eventstream.GovernedEvent { + t.Helper() + nonce, err := decision.NewNonce() + if err != nil { + t.Fatal(err) + } + intent := decision.Intent{ + Version: decision.SchemaVersion, ID: "event-intent", TenantID: "tenant-a", AgentID: "sender-a", Action: "event.publish", + Resource: "eventstream:" + event.Topic, PayloadHash: eventstream.GovernedEventPayloadHash(event.Topic, event.Payload), Risk: decision.RiskMedium, + IssuedAt: fixture.now.Unix(), ExpiresAt: fixture.now.Add(2 * time.Minute).Unix(), Nonce: nonce, KeyID: "sender-key", + } + if mandate != nil { + intent.MandateID = mandate.ID + intent.Audience = mandate.Audience + intent.Purpose = mandate.Purpose + } + if err := intent.Sign(fixture.intentPrivate); err != nil { + t.Fatal(err) + } + intentHash, err := intent.Hash() + if err != nil { + t.Fatal(err) + } + result := decision.Decision{ + Version: decision.SchemaVersion, ID: "event-decision", IntentHash: intentHash, TenantID: intent.TenantID, AgentID: intent.AgentID, + Outcome: decision.Allow, PolicyRevision: revision, RevocationEpoch: 1, ProviderID: "authority-a", IssuedAt: fixture.now.Unix(), ExpiresAt: fixture.now.Add(time.Minute).Unix(), KeyID: "authority-key", + } + if err := result.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + governed, err := eventstream.NewGovernedEvent(event, intent, result) + if err != nil { + t.Fatal(err) + } + return governed +} diff --git a/internal/enterprisecontrol/fleet_state.go b/internal/enterprisecontrol/fleet_state.go new file mode 100644 index 00000000..d64068eb --- /dev/null +++ b/internal/enterprisecontrol/fleet_state.go @@ -0,0 +1,741 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package enterprisecontrol + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/pilot-protocol/common/authority" +) + +const fleetStatePerFilePreviewBytes = 256 << 10 + +type fleetStateCursor struct { + TenantID string `json:"tenant_id"` + AgentID string `json:"agent_id"` + Revision uint64 `json:"revision"` + RootHash string `json:"root_hash"` + PendingResults []authority.FleetStateMutationResult `json:"pending_results,omitempty"` +} + +// FleetStateSyncResult is bounded operational information for daemon logs. +// File names and content deliberately do not cross this boundary. +type FleetStateSyncResult struct { + Revision uint64 + Entries int + AppliedMutations int + RejectedMutations int +} + +func (runtime *Runtime) HasFleetStateSync() bool { + return runtime != nil && runtime.fleetStateEnabled && runtime.HasFleetControl() +} + +func (runtime *Runtime) FleetStateSyncInterval() time.Duration { + if !runtime.HasFleetStateSync() { + return 0 + } + return runtime.fleetStateInterval +} + +func (runtime *Runtime) loadFleetStateCursor() error { + if runtime == nil || !runtime.fleetStateEnabled || runtime.fleetStateCursorPath == "" { + return nil + } + if _, err := os.Lstat(runtime.fleetStateCursorPath); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return fmt.Errorf("enterprise control: inspect fleet state cursor: %w", err) + } + cursor, err := readSecureJSON[fleetStateCursor](runtime.fleetStateCursorPath) + if err != nil { + return fmt.Errorf("enterprise control: read fleet state cursor: %w", err) + } + if cursor.TenantID != runtime.tenantID || cursor.AgentID != runtime.rolloutAgentID || cursor.Revision == 0 || !lowerHexSHA256(cursor.RootHash) { + return fmt.Errorf("enterprise control: invalid fleet state cursor") + } + for _, result := range cursor.PendingResults { + if result.Validate() != nil || result.TenantID != cursor.TenantID || result.AgentID != cursor.AgentID { + return fmt.Errorf("enterprise control: invalid pending fleet state result") + } + } + runtime.fleetStateRevision = cursor.Revision + runtime.fleetStateRootHash = cursor.RootHash + runtime.fleetStatePendingResults = append([]authority.FleetStateMutationResult(nil), cursor.PendingResults...) + return nil +} + +func (runtime *Runtime) saveFleetStateCursorLocked() error { + if runtime.fleetStateRevision == 0 || !lowerHexSHA256(runtime.fleetStateRootHash) { + return nil + } + return writeSecureJSON(runtime.fleetStateCursorPath, fleetStateCursor{ + TenantID: runtime.tenantID, AgentID: runtime.rolloutAgentID, + Revision: runtime.fleetStateRevision, RootHash: runtime.fleetStateRootHash, + PendingResults: append([]authority.FleetStateMutationResult(nil), runtime.fleetStatePendingResults...), + }) +} + +// SyncFleetState publishes a signed .pilot mirror, retries durable mutation +// results, and applies any short-lived authority-signed typed mutations. It is +// entirely opt-in; unmanaged nodes never call or initialize this path. +func (runtime *Runtime) SyncFleetState(ctx context.Context) (FleetStateSyncResult, error) { + if !runtime.HasFleetStateSync() { + return FleetStateSyncResult{}, fmt.Errorf("enterprise control: fleet state sync is not configured") + } + runtime.fleetStateSyncMu.Lock() + defer runtime.fleetStateSyncMu.Unlock() + + runtime.mu.Lock() + client, tenantID, agentID, keyID := runtime.rolloutClient, runtime.tenantID, runtime.rolloutAgentID, runtime.rolloutKeyID + privateKey := append(ed25519.PrivateKey(nil), runtime.rolloutPrivate...) + root := runtime.fleetStateRoot + localRevision, localRoot := runtime.fleetStateRevision, runtime.fleetStateRootHash + pending := append([]authority.FleetStateMutationResult(nil), runtime.fleetStatePendingResults...) + runtime.mu.Unlock() + + // A result is persisted before returning from a failed report, so replay it + // before asking for additional mutations. + remaining := pending[:0] + for _, result := range pending { + if err := client.ReportFleetStateMutationResult(ctx, result); err != nil { + remaining = append(remaining, result) + } + } + runtime.mu.Lock() + runtime.fleetStatePendingResults = append([]authority.FleetStateMutationResult(nil), remaining...) + _ = runtime.saveFleetStateCursorLocked() + runtime.mu.Unlock() + + remote, remoteFound, err := client.FleetStateSnapshot(ctx, tenantID, agentID) + if err != nil { + return FleetStateSyncResult{}, err + } + if remoteFound { + if remote.VerifySignature(privateKey.Public().(ed25519.PublicKey)) != nil { + return FleetStateSyncResult{}, fmt.Errorf("enterprise control: hosted fleet state is not signed by this node") + } + if remote.Revision > localRevision { + localRevision, localRoot = remote.Revision, remote.RootHash + } + } + + entries, err := scanFleetState(root, runtime.fleetStateCursorPath) + if err != nil { + return FleetStateSyncResult{}, err + } + snapshot, err := buildFleetStateSnapshot(tenantID, agentID, keyID, localRevision, localRoot, entries, privateKey) + if err != nil { + return FleetStateSyncResult{}, err + } + if err := client.ReportFleetStateSnapshot(ctx, snapshot); err != nil { + return FleetStateSyncResult{}, err + } + runtime.setFleetStateCursor(snapshot) + result := FleetStateSyncResult{Revision: snapshot.Revision, Entries: len(snapshot.Entries)} + + mutations, err := client.FleetStateMutations(ctx, tenantID, agentID) + if err != nil { + return result, err + } + for _, mutation := range mutations { + publicKey, keyErr := runtime.trust.DecisionKey(ctx, tenantID, mutation.KeyID) + if keyErr != nil || mutation.Verify(publicKey, time.Now()) != nil || mutation.AgentID != agentID { + return result, fmt.Errorf("enterprise control: invalid fleet state mutation") + } + if mutation.ExpectedRevision != snapshot.Revision { + runtime.publishFleetMutationResult(ctx, mutation, "rejected", "revision_conflict", mutation.ExpectedRevision, mutation.ExpectedRevision, privateKey) + result.RejectedMutations++ + continue + } + transaction, applyErr := prepareFleetStateMutation(root, mutation) + if applyErr != nil { + runtime.publishFleetMutationResult(ctx, mutation, "rejected", mutationDetailCode(applyErr), snapshot.Revision, snapshot.Revision, privateKey) + result.RejectedMutations++ + continue + } + if err := transaction.Apply(); err != nil { + _ = transaction.Rollback() + runtime.publishFleetMutationResult(ctx, mutation, "failed", "apply_failed", snapshot.Revision, snapshot.Revision, privateKey) + result.RejectedMutations++ + continue + } + nextEntries, scanErr := scanFleetState(root, runtime.fleetStateCursorPath) + if scanErr != nil { + _ = transaction.Rollback() + runtime.publishFleetMutationResult(ctx, mutation, "failed", "rescan_failed", snapshot.Revision, snapshot.Revision, privateKey) + result.RejectedMutations++ + continue + } + next, buildErr := buildFleetStateSnapshot(tenantID, agentID, keyID, snapshot.Revision, snapshot.RootHash, nextEntries, privateKey) + if buildErr != nil || next.Revision <= snapshot.Revision { + _ = transaction.Rollback() + runtime.publishFleetMutationResult(ctx, mutation, "failed", "snapshot_failed", snapshot.Revision, snapshot.Revision, privateKey) + result.RejectedMutations++ + continue + } + if reportErr := client.ReportFleetStateSnapshot(ctx, next); reportErr != nil { + accepted := false + if current, found, fetchErr := client.FleetStateSnapshot(ctx, tenantID, agentID); fetchErr == nil && found && current.Revision == next.Revision && current.RootHash == next.RootHash { + accepted = true + } + if !accepted { + _ = transaction.Rollback() + runtime.publishFleetMutationResult(ctx, mutation, "failed", "snapshot_report_failed", snapshot.Revision, snapshot.Revision, privateKey) + result.RejectedMutations++ + continue + } + } + if err := transaction.Commit(); err != nil { + return result, fmt.Errorf("enterprise control: commit fleet state mutation: %w", err) + } + runtime.setFleetStateCursor(next) + runtime.publishFleetMutationResult(ctx, mutation, "applied", "", snapshot.Revision, next.Revision, privateKey) + snapshot = next + result.Revision, result.Entries = next.Revision, len(next.Entries) + result.AppliedMutations++ + } + return result, nil +} + +func (runtime *Runtime) setFleetStateCursor(snapshot authority.FleetStateSnapshot) { + runtime.mu.Lock() + runtime.fleetStateRevision, runtime.fleetStateRootHash = snapshot.Revision, snapshot.RootHash + _ = runtime.saveFleetStateCursorLocked() + runtime.mu.Unlock() +} + +func (runtime *Runtime) publishFleetMutationResult(ctx context.Context, mutation authority.FleetStateMutation, status, detail string, from, to uint64, privateKey ed25519.PrivateKey) { + result := authority.FleetStateMutationResult{ + Version: authority.FleetStateVersion, TenantID: mutation.TenantID, AgentID: mutation.AgentID, + MutationID: mutation.ID, Status: status, DetailCode: detail, FromRevision: from, + ToRevision: to, ObservedAt: time.Now().UTC().Unix(), KeyID: runtime.rolloutKeyID, + } + if err := result.Sign(privateKey); err != nil { + return + } + if err := runtime.rolloutClient.ReportFleetStateMutationResult(ctx, result); err == nil { + return + } + runtime.mu.Lock() + for _, existing := range runtime.fleetStatePendingResults { + if existing.MutationID == result.MutationID { + runtime.mu.Unlock() + return + } + } + runtime.fleetStatePendingResults = append(runtime.fleetStatePendingResults, result) + _ = runtime.saveFleetStateCursorLocked() + runtime.mu.Unlock() +} + +func buildFleetStateSnapshot(tenantID, agentID, keyID string, baseRevision uint64, baseRoot string, entries []authority.FleetStateEntry, privateKey ed25519.PrivateKey) (authority.FleetStateSnapshot, error) { + probeRevision := baseRevision + if probeRevision == 0 { + probeRevision = 1 + } + probe, err := authority.NewFleetStateSnapshot(tenantID, agentID, probeRevision, entries, time.Now(), keyID) + if err != nil { + return authority.FleetStateSnapshot{}, err + } + revision := baseRevision + if revision == 0 { + revision = 1 + } else if probe.RootHash != baseRoot { + revision++ + } + snapshot, err := authority.NewFleetStateSnapshot(tenantID, agentID, revision, entries, time.Now(), keyID) + if err != nil { + return authority.FleetStateSnapshot{}, err + } + if err := snapshot.Sign(privateKey); err != nil { + return authority.FleetStateSnapshot{}, err + } + return snapshot, nil +} + +func scanFleetState(root, cursorPath string) ([]authority.FleetStateEntry, error) { + var entries []authority.FleetStateEntry + visibleBytes := 0 + err := filepath.WalkDir(root, func(current string, item fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if current == root { + return nil + } + if samePath(current, cursorPath) || strings.HasPrefix(item.Name(), ".enterprise-control-state-") || strings.HasPrefix(item.Name(), ".pilot-fleet-mutation-") { + if item.IsDir() { + return filepath.SkipDir + } + return nil + } + if len(entries) >= authority.MaxFleetStateEntries { + return fmt.Errorf("enterprise control: fleet state entry limit exceeded") + } + relative, err := filepath.Rel(root, current) + if err != nil || relative == "." || strings.HasPrefix(relative, "..") { + return fmt.Errorf("enterprise control: invalid fleet state path") + } + relative = filepath.ToSlash(relative) + info, err := item.Info() + if err != nil { + return err + } + modified := info.ModTime().UTC().Unix() + if modified <= 0 { + modified = 1 + } + entry := authority.FleetStateEntry{Path: relative, Mode: uint32(info.Mode().Perm()), ModifiedAt: modified} + switch { + case info.Mode()&os.ModeSymlink != 0: + target, readErr := os.Readlink(current) + if readErr != nil { + return readErr + } + sum := sha256.Sum256([]byte(target)) + entry.Kind, entry.Size, entry.Hash = authority.FleetStateSymlink, uint64(len(target)), hex.EncodeToString(sum[:]) + entry.Protected, entry.ProtectionReason = true, "symlink_target_hidden" + case info.IsDir(): + entry.Kind = authority.FleetStateDirectory + case info.Mode().IsRegular(): + fileEntry, readErr := scanFleetStateFile(current, entry, authority.MaxFleetStateVisibleBytes-visibleBytes) + if readErr != nil { + return readErr + } + entry = fileEntry + visibleBytes += len(entry.Content) + default: + entry.Kind, entry.Protected, entry.ProtectionReason = authority.FleetStateFile, true, "special_file_hidden" + sum := sha256.Sum256([]byte(info.Mode().String())) + entry.Hash = hex.EncodeToString(sum[:]) + } + entries = append(entries, entry) + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path }) + return entries, nil +} + +func scanFleetStateFile(filename string, entry authority.FleetStateEntry, budget int) (authority.FleetStateEntry, error) { + entry.Kind = authority.FleetStateFile + file, err := os.Open(filename) + if err != nil { + return entry, err + } + hash := sha256.New() + size, err := io.Copy(hash, file) + _ = file.Close() + if err != nil || size < 0 { + return entry, fmt.Errorf("enterprise control: hash fleet state file: %w", err) + } + entry.Size, entry.Hash = uint64(size), hex.EncodeToString(hash.Sum(nil)) + if protected, reason := fleetContentProtected(entry.Path); protected { + entry.Protected, entry.ProtectionReason = true, reason + return entry, nil + } + if budget <= 0 { + entry.Protected, entry.ProtectionReason = true, "visibility_budget_exhausted" + return entry, nil + } + want := int64(fleetStatePerFilePreviewBytes) + if size < want { + want = size + } + if int64(budget) < want { + want = int64(budget) + } + contents := make([]byte, int(want)) + file, err = os.Open(filename) + if err != nil { + return entry, err + } + offset := int64(0) + if size > want { + offset = size - want + _, err = file.Seek(offset, io.SeekStart) + } + if err == nil { + _, err = io.ReadFull(file, contents) + } + _ = file.Close() + if err != nil { + return entry, fmt.Errorf("enterprise control: read fleet state preview: %w", err) + } + if bytes.IndexByte(contents, 0) >= 0 || !utf8.Valid(contents) { + entry.Protected, entry.ProtectionReason = true, "binary_content_hidden" + return entry, nil + } + if offset == 0 && strings.EqualFold(filepath.Ext(filename), ".json") { + if sanitized, changed, ok := redactFleetJSON(contents); ok && changed { + if len(sanitized) > budget || len(sanitized) > fleetStatePerFilePreviewBytes { + entry.Protected, entry.ProtectionReason = true, "sanitized_content_exceeds_budget" + return entry, nil + } + contents, entry.Redacted = sanitized, true + } + } + entry.Content = contents + entry.ContentOffset = uint64(offset) + entry.Truncated = offset > 0 + contentHash := sha256.Sum256(contents) + entry.ContentHash = hex.EncodeToString(contentHash[:]) + return entry, nil +} + +func redactFleetJSON(contents []byte) ([]byte, bool, bool) { + decoder := json.NewDecoder(bytes.NewReader(contents)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, false, false + } + changed := redactFleetJSONValue(value) + if !changed { + return contents, false, true + } + sanitized, err := json.MarshalIndent(value, "", " ") + return sanitized, true, err == nil +} + +func redactFleetJSONValue(value any) bool { + changed := false + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + if sensitiveFleetKey(key) { + typed[key], changed = "[REDACTED]", true + continue + } + changed = redactFleetJSONValue(child) || changed + } + case []any: + for _, child := range typed { + changed = redactFleetJSONValue(child) || changed + } + } + return changed +} + +func sensitiveFleetKey(value string) bool { + normalized := strings.NewReplacer("-", "", "_", "", ".", "").Replace(strings.ToLower(value)) + for _, fragment := range []string{"password", "passwd", "secret", "token", "apikey", "privatekey", "seed", "credential", "bearer", "cookie"} { + if strings.Contains(normalized, fragment) { + return true + } + } + return false +} + +func fleetContentProtected(relative string) (bool, string) { + base := strings.ToLower(filepath.Base(relative)) + if base == ".env" || strings.HasSuffix(base, ".env") { + return true, "environment_secrets_hidden" + } + for _, suffix := range []string{".key", ".pem", ".p12", ".pfx", ".keystore", ".jks"} { + if strings.HasSuffix(base, suffix) { + return true, "private_key_material_hidden" + } + } + for _, fragment := range []string{"private-key", "private_key", "credential", "secret", "seed", "access-token", "refresh-token"} { + if strings.Contains(base, fragment) { + return true, "credential_material_hidden" + } + } + return false, "" +} + +func mutationDetailCode(err error) string { + var rejection *fleetMutationRejection + if errors.As(err, &rejection) { + return rejection.code + } + return "mutation_invalid" +} + +type fleetMutationRejection struct{ code string } + +func (err *fleetMutationRejection) Error() string { return err.code } + +type fleetMutationBackup struct { + target string + backup string + hadOriginal bool +} + +type fleetMutationTransaction struct { + root string + backupRoot string + mutation authority.FleetStateMutation + backups []fleetMutationBackup + createdDirs []string + applied bool +} + +func prepareFleetStateMutation(root string, mutation authority.FleetStateMutation) (*fleetMutationTransaction, error) { + paths := make([]string, 0, len(mutation.Operations)) + for _, operation := range mutation.Operations { + if fleetMutationPathProtected(operation.Path) { + return nil, &fleetMutationRejection{code: "protected_path"} + } + target, err := confinedFleetPath(root, operation.Path) + if err != nil { + return nil, &fleetMutationRejection{code: "path_not_confined"} + } + for _, existing := range paths { + if strings.HasPrefix(target, existing+string(os.PathSeparator)) || strings.HasPrefix(existing, target+string(os.PathSeparator)) { + return nil, &fleetMutationRejection{code: "overlapping_paths"} + } + } + paths = append(paths, target) + info, statErr := os.Lstat(target) + if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return nil, &fleetMutationRejection{code: "path_unreadable"} + } + if statErr == nil { + if info.Mode()&os.ModeSymlink != 0 { + return nil, &fleetMutationRejection{code: "symlink_rejected"} + } + if operation.ExpectedHash != "" { + if !info.Mode().IsRegular() { + return nil, &fleetMutationRejection{code: "expected_hash_not_file"} + } + hash, hashErr := hashFleetFile(target) + if hashErr != nil || hash != operation.ExpectedHash { + return nil, &fleetMutationRejection{code: "expected_hash_mismatch"} + } + } + if info.IsDir() { + protected := false + _ = filepath.WalkDir(target, func(child string, item fs.DirEntry, err error) error { + if err == nil { + relative, _ := filepath.Rel(root, child) + protected = protected || fleetMutationPathProtected(filepath.ToSlash(relative)) + } + return nil + }) + if protected { + return nil, &fleetMutationRejection{code: "protected_descendant"} + } + } + } else if operation.Kind == authority.FleetStateDeletePath || operation.ExpectedHash != "" { + return nil, &fleetMutationRejection{code: "target_not_found"} + } + } + backupRoot, err := os.MkdirTemp(filepath.Dir(root), ".pilot-fleet-mutation-") + if err != nil { + return nil, err + } + if err := os.Chmod(backupRoot, 0o700); err != nil { + _ = os.RemoveAll(backupRoot) + return nil, err + } + return &fleetMutationTransaction{root: root, backupRoot: backupRoot, mutation: mutation}, nil +} + +func (transaction *fleetMutationTransaction) Apply() error { + for index, operation := range transaction.mutation.Operations { + target, err := confinedFleetPath(transaction.root, operation.Path) + if err != nil { + return err + } + backup := filepath.Join(transaction.backupRoot, fmt.Sprintf("%06d", index)) + item := fleetMutationBackup{target: target, backup: backup} + if _, err := os.Lstat(target); err == nil { + if err := os.Rename(target, backup); err != nil { + return err + } + item.hadOriginal = true + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + transaction.backups = append(transaction.backups, item) + switch operation.Kind { + case authority.FleetStateDeletePath: + continue + case authority.FleetStateMakeDirectory: + if err := transaction.mkdirParents(filepath.Dir(target)); err != nil { + return err + } + mode := fs.FileMode(operation.Mode) + if mode == 0 { + mode = 0o700 + } + if err := os.Mkdir(target, mode); err != nil { + return err + } + case authority.FleetStatePutFile: + if err := transaction.mkdirParents(filepath.Dir(target)); err != nil { + return err + } + mode := fs.FileMode(operation.Mode) + if mode == 0 { + mode = 0o600 + } + temporary, err := os.CreateTemp(filepath.Dir(target), ".pilot-state-put-") + if err != nil { + return err + } + temporaryPath := temporary.Name() + if err = temporary.Chmod(mode); err == nil { + _, err = temporary.Write(operation.Content) + } + if err == nil { + err = temporary.Sync() + } + closeErr := temporary.Close() + if err == nil { + err = closeErr + } + if err == nil { + err = os.Rename(temporaryPath, target) + } + _ = os.Remove(temporaryPath) + if err != nil { + return err + } + default: + return fmt.Errorf("unsupported operation") + } + } + transaction.applied = true + return nil +} + +func (transaction *fleetMutationTransaction) mkdirParents(directory string) error { + missing := []string{} + current := directory + for current != transaction.root { + info, err := os.Lstat(current) + if err == nil { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("unsafe parent") + } + break + } + if !errors.Is(err, os.ErrNotExist) { + return err + } + missing = append(missing, current) + current = filepath.Dir(current) + if current == "." || !strings.HasPrefix(directory+string(os.PathSeparator), transaction.root+string(os.PathSeparator)) { + return fmt.Errorf("parent escapes state root") + } + } + for index := len(missing) - 1; index >= 0; index-- { + if err := os.Mkdir(missing[index], 0o700); err != nil { + return err + } + transaction.createdDirs = append(transaction.createdDirs, missing[index]) + } + return nil +} + +func (transaction *fleetMutationTransaction) Rollback() error { + var first error + for index := len(transaction.backups) - 1; index >= 0; index-- { + backup := transaction.backups[index] + if err := os.RemoveAll(backup.target); err != nil && first == nil { + first = err + } + if backup.hadOriginal { + if err := os.Rename(backup.backup, backup.target); err != nil && first == nil { + first = err + } + } + } + for index := len(transaction.createdDirs) - 1; index >= 0; index-- { + _ = os.Remove(transaction.createdDirs[index]) + } + if err := os.RemoveAll(transaction.backupRoot); err != nil && first == nil { + first = err + } + return first +} + +func (transaction *fleetMutationTransaction) Commit() error { + return os.RemoveAll(transaction.backupRoot) +} + +func confinedFleetPath(root, relative string) (string, error) { + cleaned := filepath.Clean(filepath.FromSlash(relative)) + if cleaned == "." || filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("invalid path") + } + target := filepath.Join(root, cleaned) + rel, err := filepath.Rel(root, target) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("path escapes state root") + } + current := root + parts := strings.Split(cleaned, string(os.PathSeparator)) + for _, part := range parts[:len(parts)-1] { + current = filepath.Join(current, part) + if info, err := os.Lstat(current); err == nil && info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("symlink parent") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + } + return target, nil +} + +func fleetMutationPathProtected(relative string) bool { + lower := strings.ToLower(filepath.ToSlash(relative)) + base := strings.ToLower(filepath.Base(lower)) + if base == ".enterprise-fleet-state-cursor.json" || base == ".enterprise-control-state.json" || base == ".enterprise-fleet-control.json" || base == "enterprise-control.json" { + return true + } + if protected, _ := fleetContentProtected(relative); protected { + return true + } + for _, fragment := range []string{"trust", "policy", "identity", "receipt", "continuation", "keyring", "authority"} { + if strings.Contains(base, fragment) { + return true + } + } + return strings.Contains(lower, "/keys/") || strings.Contains(lower, "/identity/") || strings.Contains(lower, "/secrets/") +} + +func hashFleetFile(filename string) (string, error) { + file, err := os.Open(filename) + if err != nil { + return "", err + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func lowerHexSHA256(value string) bool { + if len(value) != 64 { + return false + } + decoded, err := hex.DecodeString(value) + return err == nil && hex.EncodeToString(decoded) == value +} + +func samePath(left, right string) bool { + return filepath.Clean(left) == filepath.Clean(right) +} diff --git a/internal/enterprisecontrol/fleet_state_test.go b/internal/enterprisecontrol/fleet_state_test.go new file mode 100644 index 00000000..1be06d0d --- /dev/null +++ b/internal/enterprisecontrol/fleet_state_test.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package enterprisecontrol + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/pilot-protocol/common/authority" +) + +func TestFleetStateScannerRedactsAndProtectsSensitiveMaterial(t *testing.T) { + root := t.TempDir() + if err := os.Chmod(root, 0o700); err != nil { + t.Fatal(err) + } + writeFleetTestFile(t, filepath.Join(root, "settings.json"), []byte(`{"endpoint":"https://example.test","api_token":"do-not-export","nested":{"password":"also-secret"}}`), 0o600) + writeFleetTestFile(t, filepath.Join(root, "notes.txt"), []byte("visible operator note\n"), 0o600) + writeFleetTestFile(t, filepath.Join(root, "intent.seed"), []byte("private seed bytes"), 0o600) + if err := os.Symlink("notes.txt", filepath.Join(root, "notes-link")); err != nil { + t.Fatal(err) + } + + entries, err := scanFleetState(root, filepath.Join(root, ".enterprise-fleet-state-cursor.json")) + if err != nil { + t.Fatal(err) + } + byPath := make(map[string]authority.FleetStateEntry, len(entries)) + for _, entry := range entries { + byPath[entry.Path] = entry + } + settings := byPath["settings.json"] + if !settings.Redacted || settings.Protected || bytes.Contains(settings.Content, []byte("do-not-export")) || bytes.Contains(settings.Content, []byte("also-secret")) || !bytes.Contains(settings.Content, []byte("[REDACTED]")) { + t.Fatalf("settings visibility = %+v content=%s", settings, settings.Content) + } + if notes := byPath["notes.txt"]; notes.Protected || string(notes.Content) != "visible operator note\n" || notes.Hash != notes.ContentHash { + t.Fatalf("notes visibility = %+v", notes) + } + if seed := byPath["intent.seed"]; !seed.Protected || len(seed.Content) != 0 || seed.Hash == "" { + t.Fatalf("seed visibility = %+v", seed) + } + if link := byPath["notes-link"]; link.Kind != authority.FleetStateSymlink || !link.Protected || len(link.Content) != 0 { + t.Fatalf("symlink visibility = %+v", link) + } + + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + snapshot, err := buildFleetStateSnapshot("tenant-a", "agent-a", "intent-key", 0, "", entries, privateKey) + if err != nil || snapshot.Verify(privateKey.Public().(ed25519.PublicKey), time.Now()) != nil { + t.Fatalf("snapshot error=%v snapshot=%+v", err, snapshot) + } +} + +func TestFleetStateMutationIsConfinedAndRollbackSafe(t *testing.T) { + root := t.TempDir() + if err := os.Chmod(root, 0o700); err != nil { + t.Fatal(err) + } + original := []byte("before\n") + filename := filepath.Join(root, "settings.txt") + writeFleetTestFile(t, filename, original, 0o600) + originalHash := sha256.Sum256(original) + replacement := []byte("after\n") + replacementHash := sha256.Sum256(replacement) + mutation := authority.FleetStateMutation{ + Version: authority.FleetStateVersion, ID: "mutation-a", TenantID: "tenant-a", AgentID: "agent-a", ExpectedRevision: 1, + Operations: []authority.FleetStateMutationOperation{{Kind: authority.FleetStatePutFile, Path: "settings.txt", ExpectedHash: hex.EncodeToString(originalHash[:]), Content: replacement, ContentHash: hex.EncodeToString(replacementHash[:]), Mode: 0o600}}, + Reason: "Approved settings update", IssuedAt: time.Now().Unix(), ExpiresAt: time.Now().Add(time.Hour).Unix(), KeyID: "authority-key", + } + transaction, err := prepareFleetStateMutation(root, mutation) + if err != nil { + t.Fatal(err) + } + if err := transaction.Apply(); err != nil { + t.Fatal(err) + } + if contents, err := os.ReadFile(filename); err != nil || !bytes.Equal(contents, replacement) { + t.Fatalf("applied contents=%q err=%v", contents, err) + } + if err := transaction.Rollback(); err != nil { + t.Fatal(err) + } + if contents, err := os.ReadFile(filename); err != nil || !bytes.Equal(contents, original) { + t.Fatalf("rolled back contents=%q err=%v", contents, err) + } + + mutation.Operations[0].Path = "trust-bundle.json" + if _, err := prepareFleetStateMutation(root, mutation); err == nil || mutationDetailCode(err) != "protected_path" { + t.Fatalf("protected mutation error=%v", err) + } + mutation.Operations[0].Path = "../outside.txt" + if _, err := prepareFleetStateMutation(root, mutation); err == nil { + t.Fatal("path traversal mutation was accepted") + } +} + +func TestFleetStateScannerBoundsLargeTextToTailPreview(t *testing.T) { + root := t.TempDir() + if err := os.Chmod(root, 0o700); err != nil { + t.Fatal(err) + } + contents := []byte(strings.Repeat("a", fleetStatePerFilePreviewBytes) + "TAIL") + writeFleetTestFile(t, filepath.Join(root, "agent.log"), contents, 0o600) + entries, err := scanFleetState(root, filepath.Join(root, ".enterprise-fleet-state-cursor.json")) + if err != nil || len(entries) != 1 { + t.Fatalf("scan entries=%d err=%v", len(entries), err) + } + if !entries[0].Truncated || entries[0].ContentOffset == 0 || !bytes.HasSuffix(entries[0].Content, []byte("TAIL")) || len(entries[0].Content) != fleetStatePerFilePreviewBytes { + t.Fatalf("large preview = %+v", entries[0]) + } +} + +func writeFleetTestFile(t *testing.T, filename string, contents []byte, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filename, contents, mode); err != nil { + t.Fatal(err) + } +} diff --git a/internal/enterprisecontrol/mandates.go b/internal/enterprisecontrol/mandates.go new file mode 100644 index 00000000..45aadfd8 --- /dev/null +++ b/internal/enterprisecontrol/mandates.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package enterprisecontrol + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/pilot-protocol/common/authority" + "github.com/pilot-protocol/common/decision" +) + +// replaceableMandateStore keeps the verifier pointed at one stable store +// object while a successfully verified higher bundle atomically replaces its +// contents. It never treats an unavailable remote response as an empty set; +// only a signed, higher empty bundle can revoke existing delegation. +type replaceableMandateStore struct { + mu sync.RWMutex + current decision.MandateStore +} + +func newReplaceableMandateStore(store decision.MandateStore) *replaceableMandateStore { + return &replaceableMandateStore{current: store} +} + +func (store *replaceableMandateStore) Mandate(ctx context.Context, tenantID, mandateID string) (decision.Mandate, error) { + if store == nil { + return decision.Mandate{}, fmt.Errorf("mandate store is not initialized") + } + store.mu.RLock() + current := store.current + store.mu.RUnlock() + if current == nil { + return decision.Mandate{}, fmt.Errorf("mandate store is not initialized") + } + return current.Mandate(ctx, tenantID, mandateID) +} + +func (store *replaceableMandateStore) Replace(next decision.MandateStore) error { + if store == nil || next == nil { + return fmt.Errorf("mandate store is not initialized") + } + store.mu.Lock() + store.current = next + store.mu.Unlock() + return nil +} + +func loadMandateStore(path, tenantID string, trust *authority.Store) (decision.MandateStore, error) { + if trust == nil { + return nil, fmt.Errorf("trust store is required") + } + items, err := readSecureJSON[[]decision.Mandate](path) + if err != nil { + return nil, err + } + return decision.NewStaticMandateStore(context.Background(), tenantID, items, trust, trust, time.Now()) +} + +func loadMandateBundleStore(path, tenantID, agentID string, trust *authority.Store) (decision.MandateBundle, decision.MandateStore, error) { + if trust == nil { + return decision.MandateBundle{}, nil, fmt.Errorf("trust store is required") + } + bundle, err := readSecureJSON[decision.MandateBundle](path) + if err != nil { + return decision.MandateBundle{}, nil, err + } + if bundle.TenantID != tenantID || bundle.SubjectAgentID != agentID { + return decision.MandateBundle{}, nil, fmt.Errorf("bundle tenant or agent binding mismatch") + } + store, err := decision.NewStaticMandateStoreFromBundle(context.Background(), bundle, trust, trust, time.Now()) + if err != nil { + return decision.MandateBundle{}, nil, err + } + return bundle, store, nil +} diff --git a/pkg/daemon/ipc.go b/pkg/daemon/ipc.go index 75ffeb5b..3383d024 100644 --- a/pkg/daemon/ipc.go +++ b/pkg/daemon/ipc.go @@ -109,6 +109,10 @@ const ( CmdSignEnvelopeOK byte = 0x34 CmdVerifyEnvelope byte = 0x35 CmdVerifyEnvelopeOK byte = 0x36 + // CmdUnbind releases a listener owned by this IPC client while leaving + // accepted connections and the rest of the driver session intact. + CmdUnbind byte = 0x37 + CmdUnbindOK byte = 0x38 ) // Network sub-commands (second byte of CmdNetwork payload) @@ -476,6 +480,18 @@ func (c *ipcConn) trackPort(port uint16) { c.ports = append(c.ports, port) } +func (c *ipcConn) removePort(port uint16) bool { + c.rmu.Lock() + defer c.rmu.Unlock() + for index, tracked := range c.ports { + if tracked == port { + c.ports = append(c.ports[:index], c.ports[index+1:]...) + return true + } + } + return false +} + func (c *ipcConn) trackConn(connID uint32) { c.rmu.Lock() defer c.rmu.Unlock() @@ -802,6 +818,8 @@ func (s *IPCServer) dispatch(conn *ipcConn, cmd byte, reqID uint64, payload []by switch cmd { case CmdBind: s.handleBind(conn, reqID, payload) + case CmdUnbind: + s.handleUnbind(conn, reqID, payload) case CmdDial: s.handleDial(conn, reqID, payload) case CmdSend: @@ -897,6 +915,24 @@ func (s *IPCServer) handleBind(conn *ipcConn, reqID uint64, payload []byte) { }() } +func (s *IPCServer) handleUnbind(conn *ipcConn, reqID uint64, payload []byte) { + if len(payload) != 2 { + s.sendError(conn, reqID, "unbind: port is required") + return + } + port := binary.BigEndian.Uint16(payload) + if !conn.removePort(port) { + s.sendError(conn, reqID, "unbind: port is not owned by this client") + return + } + s.daemon.ports.Unbind(port) + response := make([]byte, 2) + binary.BigEndian.PutUint16(response, port) + if err := conn.writeReply(CmdUnbindOK, reqID, response); err != nil { + slog.Debug("IPC unbind reply failed", "port", port, "err", err) + } +} + func (s *IPCServer) handleDial(conn *ipcConn, reqID uint64, payload []byte) { if len(payload) < protocol.AddrSize+2 { s.sendError(conn, reqID, "dial: missing address/port") diff --git a/pkg/daemon/zz_ipc_unbind_test.go b/pkg/daemon/zz_ipc_unbind_test.go new file mode 100644 index 00000000..b3905183 --- /dev/null +++ b/pkg/daemon/zz_ipc_unbind_test.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package daemon + +import ( + "net" + "testing" + "time" +) + +func TestIPCUnbindReleasesOwnedPortForImmediateReuse(t *testing.T) { + d, server, socketPath := newIPCTestServer(t) + if err := server.Start(); err != nil { + t.Fatal(err) + } + defer server.Close() + connection, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + port := []byte{0x17, 0xD6} // 6102 + if err := writeIPCRequest(connection, CmdBind, port); err != nil { + t.Fatal(err) + } + _ = connection.SetReadDeadline(time.Now().Add(time.Second)) + if reply, err := readIPCFrame(connection); err != nil || reply[0] != CmdBindOK { + t.Fatalf("bind reply=%x err=%v", reply, err) + } + if err := writeIPCRequest(connection, CmdUnbind, port); err != nil { + t.Fatal(err) + } + if reply, err := readIPCFrame(connection); err != nil || reply[0] != CmdUnbindOK { + t.Fatalf("unbind reply=%x err=%v", reply, err) + } + if listener := d.ports.GetListener(6102); listener != nil { + t.Fatal("unbound port remained allocated") + } + if err := writeIPCRequest(connection, CmdBind, port); err != nil { + t.Fatal(err) + } + if reply, err := readIPCFrame(connection); err != nil || reply[0] != CmdBindOK { + t.Fatalf("rebind reply=%x err=%v", reply, err) + } +} diff --git a/tests/zz_hostname_test.go b/tests/zz_hostname_test.go index 0264311b..c245fd78 100644 --- a/tests/zz_hostname_test.go +++ b/tests/zz_hostname_test.go @@ -40,6 +40,8 @@ func startTestRegistry(t *testing.T) (*registryclient.Client, *registry.Server, func registerTestNode(t *testing.T, rc *registryclient.Client) (uint32, *crypto.Identity) { t.Helper() id, _ := crypto.GenerateIdentity() + setClientSigner(rc, id) + defer rc.SetSigner(nil) resp, err := rc.RegisterWithKey("", crypto.EncodePublicKey(id.PublicKey), "", nil) if err != nil { t.Fatalf("register node: %v", err) diff --git a/tests/zz_identity_test.go b/tests/zz_identity_test.go index 47be4abe..15312ba0 100644 --- a/tests/zz_identity_test.go +++ b/tests/zz_identity_test.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -287,9 +288,10 @@ func TestKeyRotationRequiresSignature(t *testing.T) { d1.Stop() } -func TestOwnerBasedReRegistration(t *testing.T) { +func TestOwnerCannotReplaceLostIdentityWithoutRecovery(t *testing.T) { t.Parallel() - // Test that a daemon with owner can re-register after losing its identity file + // An owner/email string is not proof of key possession. Losing the identity + // must require the explicit recover_identity flow. env := NewTestEnv(t) identityDir := t.TempDir() @@ -330,32 +332,10 @@ func TestOwnerBasedReRegistration(t *testing.T) { IdentityPath: identityPath, Email: "agent@example.com", }) - if err := d2.Start(); err != nil { - t.Fatalf("daemon restart: %v", err) + if err := d2.Start(); err == nil { + d2.Stop() + t.Fatal("daemon replaced a registered identity using only the owner email") + } else if !strings.Contains(err.Error(), "recover_identity") { + t.Fatalf("restart error should direct operator to recovery: %v", err) } - defer d2.Stop() - - drv2, err := driver.Connect(sockPath) - if err != nil { - t.Fatalf("connect driver 2: %v", err) - } - info2, err := drv2.Info() - if err != nil { - t.Fatalf("info 2: %v", err) - } - drv2.Close() - - nodeID2 := int(info2["node_id"].(float64)) - pubKey2 := info2["public_key"].(string) - - // Same node_id (owner recovery), but new keypair - if nodeID2 != nodeID1 { - t.Errorf("node_id should be same after owner recovery: %d -> %d", nodeID1, nodeID2) - } - if pubKey2 == pubKey1 { - t.Error("public key should change after identity file loss + owner recovery") - } - - t.Logf("owner recovery: node_id=%d (same=%v), new_pubkey=%s...", - nodeID2, nodeID2 == nodeID1, pubKey2[:16]) } diff --git a/tests/zz_integration_test.go b/tests/zz_integration_test.go index f47fa043..3aaa8960 100644 --- a/tests/zz_integration_test.go +++ b/tests/zz_integration_test.go @@ -797,8 +797,8 @@ func TestIntegration_DirectorySyncE2E(t *testing.T) { defer cleanup() ownerID, _ := registerTestNode(t, rc) - node2ID, _ := registerTestNode(t, rc) - node3ID, _ := registerTestNode(t, rc) + node2ID, node2Identity := registerTestNode(t, rc) + node3ID, node3Identity := registerTestNode(t, rc) netResp, err := rc.CreateNetwork(ownerID, "dirsync-e2e", "open", "", TestAdminToken, true) if err != nil { @@ -806,10 +806,12 @@ func TestIntegration_DirectorySyncE2E(t *testing.T) { } netID := uint16(netResp["network_id"].(float64)) + setClientSigner(rc, node2Identity) _, err = rc.JoinNetwork(node2ID, netID, "", 0, TestAdminToken) if err != nil { t.Fatalf("join node2: %v", err) } + setClientSigner(rc, node3Identity) _, err = rc.JoinNetwork(node3ID, netID, "", 0, TestAdminToken) if err != nil { t.Fatalf("join node3: %v", err) @@ -1007,16 +1009,24 @@ func TestIntegration_WebhookDLQWithRealServer(t *testing.T) { t.Fatalf("create network: %v", err) } - // Wait for retries to exhaust (3 retries * fast backoff) - time.Sleep(500 * time.Millisecond) - - whResp, err := rc.GetWebhook(TestAdminToken) - if err != nil { - t.Fatalf("get webhook: %v", err) + // Wait for retries to exhaust. The full integration suite runs many real + // network tests in parallel, so a fixed sleep is not a reliable completion + // signal on a loaded CI worker. + deadline := time.Now().Add(10 * time.Second) + var whResp map[string]interface{} + var failed, delivered float64 + for { + whResp, err = rc.GetWebhook(TestAdminToken) + if err != nil { + t.Fatalf("get webhook: %v", err) + } + failed, _ = whResp["failed"].(float64) + delivered, _ = whResp["delivered"].(float64) + if failed > 0 || time.Now().After(deadline) { + break + } + time.Sleep(25 * time.Millisecond) } - - failed, _ := whResp["failed"].(float64) - delivered, _ := whResp["delivered"].(float64) t.Logf("webhook stats: delivered=%v failed=%v", delivered, failed) if failed == 0 { @@ -1097,9 +1107,9 @@ func TestIntegration_MetricsReflectOperations(t *testing.T) { } defer rc.Close() - id1, _ := registerTestNode(t, rc) - id2, _ := registerTestNode(t, rc) - id3, _ := registerTestNode(t, rc) + id1, identity1 := registerTestNode(t, rc) + id2, identity2 := registerTestNode(t, rc) + id3, identity3 := registerTestNode(t, rc) netResp, err := rc.CreateNetwork(id1, "metrics-ops-net", "open", "", TestAdminToken, true) if err != nil { @@ -1107,9 +1117,18 @@ func TestIntegration_MetricsReflectOperations(t *testing.T) { } netID := uint16(netResp["network_id"].(float64)) - _, _ = rc.JoinNetwork(id2, netID, "", 0, TestAdminToken) - _, _ = rc.JoinNetwork(id3, netID, "", 0, TestAdminToken) - _, _ = rc.PromoteMember(netID, id1, id2, TestAdminToken) + setClientSigner(rc, identity2) + if _, err := rc.JoinNetwork(id2, netID, "", 0, TestAdminToken); err != nil { + t.Fatalf("join node 2: %v", err) + } + setClientSigner(rc, identity3) + if _, err := rc.JoinNetwork(id3, netID, "", 0, TestAdminToken); err != nil { + t.Fatalf("join node 3: %v", err) + } + setClientSigner(rc, identity1) + if _, err := rc.PromoteMember(netID, id1, id2, TestAdminToken); err != nil { + t.Fatalf("promote node 2: %v", err) + } body := fetchMetrics(t, dashAddr) diff --git a/tests/zz_key_lifecycle_test.go b/tests/zz_key_lifecycle_test.go index 79c89f96..130ae877 100644 --- a/tests/zz_key_lifecycle_test.go +++ b/tests/zz_key_lifecycle_test.go @@ -345,6 +345,7 @@ func TestKeyAgeDaysInResolve(t *testing.T) { nodeID1 := uint32(resp1["node_id"].(float64)) id2, _ := crypto.GenerateIdentity() + setClientSigner(rc, id2) resp2, err := rc.RegisterWithKey("127.0.0.1:5002", crypto.EncodePublicKey(id2.PublicKey), "", nil) if err != nil { t.Fatalf("register node 2: %v", err) diff --git a/tests/zz_network_test.go b/tests/zz_network_test.go index 026e08e1..8e17193a 100644 --- a/tests/zz_network_test.go +++ b/tests/zz_network_test.go @@ -437,8 +437,8 @@ func TestListNodes(t *testing.T) { defer cleanup() nodeA, _ := registerTestNode(t, rc) - nodeB, _ := registerTestNode(t, rc) - nodeC, _ := registerTestNode(t, rc) + nodeB, identityB := registerTestNode(t, rc) + nodeC, identityC := registerTestNode(t, rc) resp, err := rc.CreateNetwork(nodeA, "members-test", "open", "", TestAdminToken, false) if err != nil { @@ -446,8 +446,14 @@ func TestListNodes(t *testing.T) { } netID := uint16(resp["network_id"].(float64)) - rc.JoinNetwork(nodeB, netID, "", 0, TestAdminToken) - rc.JoinNetwork(nodeC, netID, "", 0, TestAdminToken) + setClientSigner(rc, identityB) + if _, err := rc.JoinNetwork(nodeB, netID, "", 0, TestAdminToken); err != nil { + t.Fatalf("join node B: %v", err) + } + setClientSigner(rc, identityC) + if _, err := rc.JoinNetwork(nodeC, netID, "", 0, TestAdminToken); err != nil { + t.Fatalf("join node C: %v", err) + } nodesResp, err := rc.ListNodes(netID) if err != nil { diff --git a/tests/zz_registry_hardening_test.go b/tests/zz_registry_hardening_test.go index 5eabbc7d..50381307 100644 --- a/tests/zz_registry_hardening_test.go +++ b/tests/zz_registry_hardening_test.go @@ -234,7 +234,11 @@ func TestRegistrySnapshotChecksum(t *testing.T) { // when the maximum connection count is reached. func TestRegistryConnectionLimit(t *testing.T) { requireRealNetwork(t) - t.Parallel() + // Keep this test serial. It deliberately holds the registry's complete + // connection allowance open and verifies an immediate rejection. Running + // it beside the package's high-volume real-network stress cases can exhaust + // the host's ephemeral ports and turn this deterministic registry check into + // an operating-system resource race. reg := registry.New("127.0.0.1:9001") // Set a very low connection limit for testing diff --git a/tests/zz_security_phase2_test.go b/tests/zz_security_phase2_test.go index 000ae576..9ad36ea7 100644 --- a/tests/zz_security_phase2_test.go +++ b/tests/zz_security_phase2_test.go @@ -178,17 +178,32 @@ func TestBeaconNodeCapRejectsNewAtMax(t *testing.T) { s, addr := startTestBeacon(t) defer s.Close() - // Discover 5 nodes - for i := uint32(1); i <= 5; i++ { - msg := make([]byte, 5) - msg[0] = protocol.BeaconMsgDiscover - binary.BigEndian.PutUint32(msg[1:5], i) - sendUDP(t, addr, msg) + // UDP delivery and goroutine scheduling are intentionally best-effort. + // Retransmit the idempotent discovery messages until the beacon exposes the + // expected state instead of assuming five packets are processed in 50 ms. + conn, err := net.DialUDP("udp", nil, addr) + if err != nil { + t.Fatalf("dial beacon: %v", err) } + defer conn.Close() - time.Sleep(50 * time.Millisecond) - if s.LocalNodeCount() != 5 { - t.Fatalf("expected 5 local nodes, got %d", s.LocalNodeCount()) + deadline := time.Now().Add(5 * time.Second) + for { + for i := uint32(1); i <= 5; i++ { + msg := make([]byte, 5) + msg[0] = protocol.BeaconMsgDiscover + binary.BigEndian.PutUint32(msg[1:5], i) + if _, err := conn.Write(msg); err != nil { + t.Fatalf("send discovery for node %d: %v", i, err) + } + } + + if count := s.LocalNodeCount(); count == 5 { + break + } else if time.Now().After(deadline) { + t.Fatalf("expected 5 local nodes before deadline, got %d", count) + } + time.Sleep(25 * time.Millisecond) } } diff --git a/tests/zz_test_beacon_helpers_test.go b/tests/zz_test_beacon_helpers_test.go index c8e69a75..7e2342aa 100644 --- a/tests/zz_test_beacon_helpers_test.go +++ b/tests/zz_test_beacon_helpers_test.go @@ -37,7 +37,11 @@ func sendUDP(t *testing.T, addr *net.UDPAddr, data []byte) []byte { t.Fatalf("dial: %v", err) } defer conn.Close() - conn.SetDeadline(time.Now().Add(200 * time.Millisecond)) + // A package-wide run executes the real-network tests in parallel with the + // daemon and external-delivery suites. Give the scheduler enough room to + // service this UDP round trip; 200 ms made a healthy beacon look broken + // under full-suite load even though focused repetitions always passed. + conn.SetDeadline(time.Now().Add(2 * time.Second)) if _, err := conn.Write(data); err != nil { t.Fatalf("write: %v", err) From 92ce722ce69bce3761d2f02cf2e70922dc92bb79 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sun, 2 Aug 2026 21:05:07 +0300 Subject: [PATCH 2/6] sec(enterprise): require explicit distinct fleet state_directory (no config-dir default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L1: with fleet state sync enabled and state_directory empty, the scan root defaulted to '.', the directory holding enterprise-control.json — so scanFleetState shipped 256KB previews of any operator-added text file there to the authority as signed telemetry. Fail closed: require state_directory to be set explicitly and to be distinct from the config directory. SECURITY_REVIEW_v1.14 L1. Co-Authored-By: Claude Fable 5 --- internal/enterprisecontrol/control.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/enterprisecontrol/control.go b/internal/enterprisecontrol/control.go index 4e3b0bd6..3e01a9c5 100644 --- a/internal/enterprisecontrol/control.go +++ b/internal/enterprisecontrol/control.go @@ -579,14 +579,24 @@ func Load(path string) (*Runtime, error) { } runtime.fleetControlPath = filepath.Join(directory, ".enterprise-fleet-control.json") if config.Fleet.StateSyncEnabled { + // state_directory must be explicit and distinct from the config + // directory. The prior empty->"." default pointed the scan root at + // the directory holding enterprise-control.json, so scanFleetState + // would ship 256KB previews of any operator-added text file there + // as signed telemetry. Fail closed rather than expose them. stateDirectory := strings.TrimSpace(config.Fleet.StateDirectory) if stateDirectory == "" { - stateDirectory = "." + return nil, fmt.Errorf("enterprise control: fleet state sync is enabled but state_directory is empty; set it to a dedicated directory distinct from the enterprise-control config directory") } stateRoot, resolveErr := resolveBundlePath(directory, stateDirectory) if resolveErr != nil { return nil, fmt.Errorf("enterprise control: fleet state directory: %w", resolveErr) } + if absState, absErr := filepath.Abs(stateRoot); absErr == nil { + if absConfig, cfgErr := filepath.Abs(directory); cfgErr == nil && absState == absConfig { + return nil, fmt.Errorf("enterprise control: fleet state_directory must be distinct from the enterprise-control config directory (%s)", directory) + } + } if err := secureDirectory(stateRoot); err != nil { return nil, fmt.Errorf("enterprise control: fleet state directory: %w", err) } From 8bd333c1cdc5ac80eac3a4edbdf3391a9e642d55 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sun, 2 Aug 2026 21:21:54 +0300 Subject: [PATCH 3/6] sec(enterprise): local idempotency for signed lifecycle commands (anti-replay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M2: a captured authority-signed restart/shutdown command could be replayed by a compromised or MITM'd authority connection every poll — and after the restart it caused — for up to its 24h TTL, because the daemon's result report goes to the attacker (who drops it) so the authority's server-side de-duplication never engages, yielding a fleet-wide shutdown/restart boot-loop. The signature is replayed, not forged. Persist a lifecycle guard (monotonic IssuedAt high-water + last command id) to the enterprise-control state dir and refuse any lifecycle command at or below it. The record is written BEFORE the daemon acts (and the write failing is fatal to the action — fail closed), so it survives the syscall.Exec restart and the replay is rejected on the next poll. Regression test covers replay, older issue-time, newer command, and restart survival. (Cert-pinning the authority channel — the other half of the MITM precondition — remains a tracked enhancement; this idempotency record already breaks the replay loop.) SECURITY_REVIEW_v1.14 M2 (idempotency). Co-Authored-By: Claude Fable 5 --- cmd/daemon/main.go | 19 +++++++- internal/enterprisecontrol/control.go | 48 +++++++++++++++++++ .../zz_lifecycle_guard_test.go | 40 ++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 internal/enterprisecontrol/zz_lifecycle_guard_test.go diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 2a3f511a..b693b23d 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -712,9 +712,17 @@ func synchronizeFleetControl(ctx context.Context, controls *enterprisecontrol.Ru } } case authority.FleetCommandRestartRuntime: - lifecycle = "restart" + if controls.LifecycleCommandAlreadyApplied(command) { + outcome, detail = "rejected", "already_applied" + } else { + lifecycle = "restart" + } case authority.FleetCommandShutdownRuntime: - lifecycle = "shutdown" + if controls.LifecycleCommandAlreadyApplied(command) { + outcome, detail = "rejected", "already_applied" + } else { + lifecycle = "shutdown" + } default: outcome, detail = "rejected", "command_not_allowlisted" } @@ -723,6 +731,13 @@ func synchronizeFleetControl(ctx context.Context, controls *enterprisecontrol.Ru continue } if outcome == "succeeded" && lifecycle != "" { + // Persist the idempotency record BEFORE acting, and fail closed if + // it can't be written — otherwise a replayed signed command could + // loop across every poll and across the restart it triggers. + if err := controls.MarkLifecycleCommandApplied(command); err != nil { + slog.Error("persist lifecycle idempotency record failed; refusing to act to avoid a replay loop", "command_id", command.ID, "err", err) + continue + } select { case remoteLifecycleRequests <- lifecycle: default: diff --git a/internal/enterprisecontrol/control.go b/internal/enterprisecontrol/control.go index 3e01a9c5..b116b69a 100644 --- a/internal/enterprisecontrol/control.go +++ b/internal/enterprisecontrol/control.go @@ -256,6 +256,7 @@ type Runtime struct { rolloutInterval time.Duration fleetInterval time.Duration fleetControlPath string + lifecycleGuardPath string fleetControl authority.FleetNodeControl fleetControlFound bool fleetStateEnabled bool @@ -578,6 +579,7 @@ func Load(path string) (*Runtime, error) { runtime.fleetInterval = 30 * time.Second } runtime.fleetControlPath = filepath.Join(directory, ".enterprise-fleet-control.json") + runtime.lifecycleGuardPath = filepath.Join(directory, ".enterprise-lifecycle-applied.json") if config.Fleet.StateSyncEnabled { // state_directory must be explicit and distinct from the config // directory. The prior empty->"." default pointed the scan root at @@ -975,6 +977,52 @@ func (runtime *Runtime) FleetCommands(ctx context.Context) ([]authority.FleetCom return verified, nil } +// lifecycleGuardState records the last authority-signed lifecycle command that +// was applied. It defeats replay of a captured restart/shutdown command: a +// compromised or MITM'd authority connection could otherwise re-present a +// still-valid signed command every poll (and after the restart it caused) for +// up to its 24h TTL, since the daemon's result report is dropped by the +// attacker so server-side de-duplication never engages. IssuedAt is a +// monotonic high-water; LastID guards an exact same-second re-send. +type lifecycleGuardState struct { + IssuedAt int64 `json:"issued_at"` + LastID string `json:"last_id"` +} + +// LifecycleCommandAlreadyApplied reports whether a signed lifecycle command has +// already been acted on (by monotonic issue time or exact id). Missing or +// unreadable state reads as "not applied" (first boot); durability rests on the +// fail-closed MarkLifecycleCommandApplied step. +func (runtime *Runtime) LifecycleCommandAlreadyApplied(command authority.FleetCommand) bool { + if runtime == nil || runtime.lifecycleGuardPath == "" { + return false + } + state, err := readSecureJSON[lifecycleGuardState](runtime.lifecycleGuardPath) + if err != nil { + return false + } + return command.IssuedAt <= state.IssuedAt || (command.ID != "" && command.ID == state.LastID) +} + +// MarkLifecycleCommandApplied durably records a lifecycle command as applied +// BEFORE the daemon acts on it, so the action cannot loop across polls or the +// restart it triggers. The caller must treat an error as fatal to the action +// (fail closed) rather than proceed unrecorded. +func (runtime *Runtime) MarkLifecycleCommandApplied(command authority.FleetCommand) error { + if runtime == nil || runtime.lifecycleGuardPath == "" { + return fmt.Errorf("enterprise control: lifecycle guard not configured") + } + state, err := readSecureJSON[lifecycleGuardState](runtime.lifecycleGuardPath) + if err != nil { + state = lifecycleGuardState{} + } + if command.IssuedAt > state.IssuedAt { + state.IssuedAt = command.IssuedAt + } + state.LastID = command.ID + return writeSecureJSON(runtime.lifecycleGuardPath, state) +} + // ReportFleetCommandResult signs an allowlisted-command outcome. The remote // service keeps the short result code, never daemon logs or raw errors. func (runtime *Runtime) ReportFleetCommandResult(ctx context.Context, commandID, outcome, detailCode string) error { diff --git a/internal/enterprisecontrol/zz_lifecycle_guard_test.go b/internal/enterprisecontrol/zz_lifecycle_guard_test.go new file mode 100644 index 00000000..86c1e523 --- /dev/null +++ b/internal/enterprisecontrol/zz_lifecycle_guard_test.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package enterprisecontrol + +import ( + "path/filepath" + "testing" + + "github.com/pilot-protocol/common/authority" +) + +// TestLifecycleGuardRejectsReplay pins SECURITY_REVIEW_v1.14 finding M2: an +// authority-signed restart/shutdown command is applied at most once; a replay +// (same or older issue time, or the same id) is rejected, and the record +// survives a process restart. +func TestLifecycleGuardRejectsReplay(t *testing.T) { + path := filepath.Join(t.TempDir(), "applied.json") + rt := &Runtime{lifecycleGuardPath: path} + cmd := authority.FleetCommand{ID: "cmd-1", IssuedAt: 1000} + + if rt.LifecycleCommandAlreadyApplied(cmd) { + t.Fatal("fresh command reported as already applied") + } + if err := rt.MarkLifecycleCommandApplied(cmd); err != nil { + t.Fatal(err) + } + if !rt.LifecycleCommandAlreadyApplied(cmd) { + t.Fatal("replay of the same command was NOT rejected") + } + if !rt.LifecycleCommandAlreadyApplied(authority.FleetCommand{ID: "cmd-old", IssuedAt: 999}) { + t.Fatal("older-issue-time command was not rejected") + } + if rt.LifecycleCommandAlreadyApplied(authority.FleetCommand{ID: "cmd-2", IssuedAt: 1001}) { + t.Fatal("a genuinely newer command was wrongly rejected") + } + // Survives a restart: a fresh Runtime reading the same persisted guard. + if restarted := (&Runtime{lifecycleGuardPath: path}); !restarted.LifecycleCommandAlreadyApplied(cmd) { + t.Fatal("idempotency record did not survive restart") + } +} From 0b4de8e4aee4637a64d850ea6c5ef38ab2648a21 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Thu, 6 Aug 2026 18:39:57 +0300 Subject: [PATCH 4/6] feat(enterprise): add hosted action control and node adoption --- cmd/control-agent/main.go | 270 ++++++++++++ cmd/daemon/main.go | 18 + cmd/daemon/managed_control_test.go | 33 ++ cmd/pilotctl/enterprise.go | 17 +- cmd/pilotctl/enterprise_adopt.go | 369 ++++++++++++++++ cmd/pilotctl/enterprise_adopt_test.go | 135 ++++++ cmd/pilotctl/enterprise_hook.go | 410 ++++++++++++++++++ cmd/pilotctl/main.go | 10 + internal/enterprisecontrol/action_hook.go | 208 ++++++++- .../action_hook_external_test.go | 96 ++++ internal/enterprisecontrol/control.go | 90 +++- internal/enterprisecontrol/control_test.go | 3 +- pkg/daemon/zz_ipc_maxclients_test.go | 6 +- pkg/daemon/zz_ipc_write_deadline_test.go | 6 +- tests/zz_ipv6_test.go | 10 +- 15 files changed, 1658 insertions(+), 23 deletions(-) create mode 100644 cmd/control-agent/main.go create mode 100644 cmd/daemon/managed_control_test.go create mode 100644 cmd/pilotctl/enterprise_adopt.go create mode 100644 cmd/pilotctl/enterprise_adopt_test.go create mode 100644 cmd/pilotctl/enterprise_hook.go create mode 100644 internal/enterprisecontrol/action_hook_external_test.go diff --git a/cmd/control-agent/main.go b/cmd/control-agent/main.go new file mode 100644 index 00000000..2ec8db2b --- /dev/null +++ b/cmd/control-agent/main.go @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Command control-agent is a headless reference node for Pilot's optional +// hosted control plane. It runs the same enterprisecontrol runtime as the Web4 +// daemon without opening a Pilot transport, making signed fleet operations +// usable beside any agent harness. It intentionally exposes no remote shell. +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/pilot-protocol/common/actionhook" + "github.com/pilot-protocol/common/authority" + "github.com/pilot-protocol/pilotprotocol/internal/enterprisecontrol" +) + +type evidenceEvent struct { + Event string `json:"event"` + CommandID string `json:"command_id,omitempty"` + Detail string `json:"detail,omitempty"` + PID int `json:"pid"` + RuntimeVersion string `json:"runtime_version"` + ObservedAt int64 `json:"observed_at"` +} + +type diagnosticRecord struct { + Version uint16 `json:"version"` + CommandID string `json:"command_id"` + Hostname string `json:"hostname"` + PID int `json:"pid"` + UID int `json:"uid"` + RuntimeVersion string `json:"runtime_version"` + PolicyRevision uint64 `json:"policy_revision"` + StartedAt int64 `json:"started_at"` + ObservedAt int64 `json:"observed_at"` +} + +func main() { + controlPath := flag.String("enterprise-control", "", "path to the signed enterprise control attachment") + runtimeVersion := flag.String("runtime-version", "pilot-control-agent/1.0.0", "reported runtime version") + nodeID := flag.Uint("node-id", 1001, "reported Pilot node ID") + poll := flag.Duration("poll-interval", 2*time.Second, "fleet control poll interval") + evidenceDirectory := flag.String("evidence-dir", "", "owner-only directory for tangible lifecycle and diagnostic evidence") + flag.Parse() + if *controlPath == "" || *evidenceDirectory == "" || *poll < 250*time.Millisecond || *poll > time.Minute || *nodeID == 0 || *nodeID > uint(^uint32(0)) { + fatalf("enterprise-control, evidence-dir, a node ID, and a 250ms-1m poll interval are required") + } + if err := os.MkdirAll(*evidenceDirectory, 0o700); err != nil { + fatalf("create evidence directory: %v", err) + } + controls, err := enterprisecontrol.Load(*controlPath) + if err != nil { + fatalf("load enterprise controls: %v", err) + } + started := time.Now().UTC() + if err := appendEvidence(*evidenceDirectory, evidenceEvent{Event: "startup", PID: os.Getpid(), RuntimeVersion: *runtimeVersion, ObservedAt: started.Unix()}); err != nil { + fatalf("record startup: %v", err) + } + if err := runTangibleHook(context.Background(), controls, *evidenceDirectory); err != nil { + fatalf("run tangible action hook: %v", err) + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + ticker := time.NewTicker(*poll) + defer ticker.Stop() + for { + lifecycle, err := synchronize(ctx, controls, uint32(*nodeID), *runtimeVersion, started, *evidenceDirectory) + if err != nil && ctx.Err() == nil { + _, _ = fmt.Fprintf(os.Stderr, "pilot-control-agent: synchronize: %v\n", err) + } + switch lifecycle { + case "restart": + if err := appendEvidence(*evidenceDirectory, evidenceEvent{Event: "restart_requested", PID: os.Getpid(), RuntimeVersion: *runtimeVersion, ObservedAt: time.Now().UTC().Unix()}); err != nil { + fatalf("record restart: %v", err) + } + executable, err := os.Executable() + if err != nil { + fatalf("resolve executable: %v", err) + } + if err := syscall.Exec(executable, os.Args, os.Environ()); err != nil { + fatalf("restart process: %v", err) + } + case "shutdown": + if err := appendEvidence(*evidenceDirectory, evidenceEvent{Event: "shutdown_requested", PID: os.Getpid(), RuntimeVersion: *runtimeVersion, ObservedAt: time.Now().UTC().Unix()}); err != nil { + fatalf("record shutdown: %v", err) + } + return + } + select { + case <-ctx.Done(): + _ = appendEvidence(*evidenceDirectory, evidenceEvent{Event: "signal_shutdown", PID: os.Getpid(), RuntimeVersion: *runtimeVersion, ObservedAt: time.Now().UTC().Unix()}) + return + case <-ticker.C: + } + } +} + +func synchronize(ctx context.Context, controls *enterprisecontrol.Runtime, nodeID uint32, runtimeVersion string, started time.Time, evidenceDirectory string) (string, error) { + reconciliation, err := controls.ReconcileFleetControl(ctx, runtimeVersion) + if err != nil { + return "", err + } + if reconciliation.Found { + if err := controls.ReportFleetControlAcknowledgement(ctx, reconciliation, runtimeVersion); err != nil { + return "", err + } + } + status := enterprisecontrol.FleetNodeStatus{ + NodeID: nodeID, AgentVersion: runtimeVersion, UptimeSeconds: uint64(time.Since(started).Seconds()), + PolicyRevision: controls.CurrentPolicyRevision(ctx), + } + if err := controls.ReportFleetStatus(ctx, status); err != nil { + return "", err + } + commands, err := controls.FleetCommands(ctx) + if err != nil { + return "", err + } + for _, command := range commands { + outcome, detail, lifecycle := executeCommand(ctx, controls, command, runtimeVersion, started, evidenceDirectory) + if err := controls.ReportFleetCommandResult(ctx, command.ID, outcome, detail); err != nil { + return "", err + } + if err := appendEvidence(evidenceDirectory, evidenceEvent{Event: "command_result", CommandID: command.ID, Detail: string(command.Kind) + ":" + outcome + ":" + detail, PID: os.Getpid(), RuntimeVersion: runtimeVersion, ObservedAt: time.Now().UTC().Unix()}); err != nil { + return "", err + } + if outcome == "succeeded" && lifecycle != "" { + if err := controls.MarkLifecycleCommandApplied(command); err != nil { + return "", err + } + return lifecycle, nil + } + } + return "", nil +} + +func executeCommand(ctx context.Context, controls *enterprisecontrol.Runtime, command authority.FleetCommand, runtimeVersion string, started time.Time, evidenceDirectory string) (string, string, string) { + switch command.Kind { + case authority.FleetCommandRefreshPolicy: + if err := controls.RefreshRollout(ctx); err != nil { + return "failed", "rollout_refresh_failed", "" + } + return "succeeded", "policy_refreshed", "" + case authority.FleetCommandExportReceipts: + if !controls.HasReceiptExport() { + return "rejected", "receipt_export_unconfigured", "" + } + if err := controls.ExportReceiptsOnce(ctx); err != nil { + return "failed", "receipt_export_failed", "" + } + return "succeeded", "receipts_exported", "" + case authority.FleetCommandReloadControl: + if err := controls.Reload(); err != nil { + return "failed", "control_reload_failed", "" + } + return "succeeded", "control_reloaded", "" + case authority.FleetCommandSyncState: + if !controls.HasFleetStateSync() { + return "rejected", "state_sync_unconfigured", "" + } + if _, err := controls.SyncFleetState(ctx); err != nil { + return "failed", "state_sync_failed", "" + } + return "succeeded", "state_synchronized", "" + case authority.FleetCommandDiagnostics: + if controls.HasFleetStateSync() { + if _, err := controls.SyncFleetState(ctx); err != nil { + return "failed", "diagnostics_sync_failed", "" + } + } + if err := writeDiagnostics(evidenceDirectory, command.ID, runtimeVersion, controls.CurrentPolicyRevision(ctx), started); err != nil { + return "failed", "diagnostics_write_failed", "" + } + return "succeeded", "diagnostics_written", "" + case authority.FleetCommandRestartRuntime: + if controls.LifecycleCommandAlreadyApplied(command) { + return "rejected", "already_applied", "" + } + return "succeeded", "restart_accepted", "restart" + case authority.FleetCommandShutdownRuntime: + if controls.LifecycleCommandAlreadyApplied(command) { + return "rejected", "already_applied", "" + } + return "succeeded", "shutdown_accepted", "shutdown" + default: + return "rejected", "command_not_allowlisted", "" + } +} + +func runTangibleHook(ctx context.Context, controls *enterprisecontrol.Runtime, evidenceDirectory string) error { + hook := controls.ActionHook() + if hook == nil { + return fmt.Errorf("managed action hook is not configured") + } + target := filepath.Join(evidenceDirectory, "hook-side-effect.txt") + if _, err := os.Stat(target); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + content := []byte("Pilot managed action hook released this tangible file write.\n") + digest := sha256.Sum256(content) + envelope, err := actionhook.NewEnvelope("file.write", "workspace:control-agent/hook-side-effect.txt", hex.EncodeToString(digest[:]), "pilot.control-agent", map[string]string{"content_type": "text/plain"}, time.Now().UTC()) + if err != nil { + return err + } + preflight, err := hook.BeforeAction(ctx, envelope) + if err != nil { + return err + } + if err := preflight.RequireUnconstrained(); err != nil { + return err + } + if err := os.WriteFile(target, content, 0o600); err != nil { + return err + } + if err := hook.AfterAction(ctx, envelope, preflight, actionhook.ObservedResult{Status: actionhook.StatusSucceeded, ObservedAt: time.Now().UTC().Unix()}); err != nil { + return err + } + return appendEvidence(evidenceDirectory, evidenceEvent{Event: "managed_hook_side_effect", Detail: "file.write:allow", PID: os.Getpid(), RuntimeVersion: "pilot-control-agent/1.0.0", ObservedAt: time.Now().UTC().Unix()}) +} + +func writeDiagnostics(directory, commandID, runtimeVersion string, policyRevision uint64, started time.Time) error { + hostname, err := os.Hostname() + if err != nil { + return err + } + record := diagnosticRecord{Version: 1, CommandID: commandID, Hostname: hostname, PID: os.Getpid(), UID: os.Getuid(), RuntimeVersion: runtimeVersion, PolicyRevision: policyRevision, StartedAt: started.Unix(), ObservedAt: time.Now().UTC().Unix()} + return writeSecureJSON(filepath.Join(directory, "diagnostics-"+commandID+".json"), record) +} + +func appendEvidence(directory string, event evidenceEvent) error { + path := filepath.Join(directory, "control-events.jsonl") + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + encodeErr := json.NewEncoder(file).Encode(event) + closeErr := file.Close() + return errors.Join(encodeErr, closeErr) +} + +func writeSecureJSON(path string, value any) error { + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + encoded = append(encoded, '\n') + if err := os.WriteFile(path, encoded, 0o600); err != nil { + return err + } + return os.Chmod(path, 0o600) +} + +func fatalf(format string, arguments ...any) { + _, _ = fmt.Fprintf(os.Stderr, "pilot-control-agent: "+format+"\n", arguments...) + os.Exit(1) +} diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index b693b23d..3bd4c93a 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -162,6 +162,11 @@ func main() { } config.ApplyToFlags(cfg) } + if *enterpriseControlPath == "" { + if discovered, ok := discoverManagedEnterpriseControl(); ok { + *enterpriseControlPath = discovered + } + } // Compat-mode 443-only defaults. When -transport=compat is selected // and the operator hasn't explicitly overridden -registry/-registry-tls/ @@ -642,6 +647,19 @@ shutdownLoop: } } +func discoverManagedEnterpriseControl() (string, bool) { + home, err := os.UserHomeDir() + if err != nil { + return "", false + } + path := filepath.Join(home, ".pilot", "managed", "enterprise-control.json") + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 { + return "", false + } + return path, true +} + // synchronizeFleetControl reports bounded local health and runs only the // fixed, authority-signed maintenance commands. It intentionally has no // generic process execution, file access, shell, or network-dial capability. diff --git a/cmd/daemon/managed_control_test.go b/cmd/daemon/managed_control_test.go new file mode 100644 index 00000000..e0e8b439 --- /dev/null +++ b/cmd/daemon/managed_control_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDiscoverManagedEnterpriseControlRequiresOwnerOnlyRegularFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if _, ok := discoverManagedEnterpriseControl(); ok { + t.Fatal("missing attachment was discovered") + } + path := filepath.Join(home, ".pilot", "managed", "enterprise-control.json") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if discovered, ok := discoverManagedEnterpriseControl(); !ok || discovered != path { + t.Fatalf("discovered=%q ok=%v", discovered, ok) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + if _, ok := discoverManagedEnterpriseControl(); ok { + t.Fatal("group/world-readable attachment was discovered") + } +} diff --git a/cmd/pilotctl/enterprise.go b/cmd/pilotctl/enterprise.go index 074019c8..76167a4b 100644 --- a/cmd/pilotctl/enterprise.go +++ b/cmd/pilotctl/enterprise.go @@ -23,7 +23,7 @@ import ( "github.com/pilot-protocol/common/decisionpolicy" ) -const enterpriseHelpText = `Usage: pilotctl enterprise --endpoint --tenant [flags] +const enterpriseHelpText = `Usage: pilotctl enterprise [flags] Read the signed enterprise control state from an authority, or produce its read-only dashboard URL. The policy subcommands submit already-signed @@ -66,6 +66,13 @@ Workflow operations: pilotctl enterprise workflow list [--limit <1-1000>] [flags] pilotctl enterprise workflow status --id [flags] pilotctl enterprise workflow cancel --id --reason [flags] + +External agent hooks (JSON request on stdin; normally invoked by pilot-mcp): + pilotctl --json enterprise hook pre [--control ] + pilotctl --json enterprise hook post [--control ] + +One-time managed adoption (normally invoked by pilot-mcp setup): + PILOT_ENROLLMENT_TOKEN=... pilotctl --json enterprise adopt --endpoint https://management.example ` // cmdEnterprise is a read-only operator surface over the authority's signed @@ -73,9 +80,11 @@ Workflow operations: // operations; this CLI deliberately does not turn a terminal into a bypass. func cmdEnterprise(args []string) { if len(args) == 0 { - fatalHint("invalid_argument", "available: pilotctl enterprise status | dashboard-url | trust | policy | mandate | receipt | workflow", "missing enterprise subcommand") + fatalHint("invalid_argument", "available: pilotctl enterprise adopt | status | dashboard-url | trust | policy | mandate | receipt | workflow", "missing enterprise subcommand") } switch args[0] { + case "adopt": + cmdEnterpriseAdopt(args[1:]) case "status": cmdEnterpriseStatus(args[1:]) case "dashboard-url": @@ -90,8 +99,10 @@ func cmdEnterprise(args []string) { cmdEnterpriseReceipt(args[1:]) case "workflow": cmdEnterpriseWorkflow(args[1:]) + case "hook": + cmdEnterpriseHook(args[1:]) default: - fatalHint("invalid_argument", "available: status, dashboard-url, trust, policy, mandate, receipt, workflow", "unknown enterprise subcommand: %s", args[0]) + fatalHint("invalid_argument", "available: adopt, status, dashboard-url, trust, policy, mandate, receipt, workflow, hook", "unknown enterprise subcommand: %s", args[0]) } } diff --git a/cmd/pilotctl/enterprise_adopt.go b/cmd/pilotctl/enterprise_adopt.go new file mode 100644 index 00000000..c9f11245 --- /dev/null +++ b/cmd/pilotctl/enterprise_adopt.go @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/pilot-protocol/common/actionregistry" + "github.com/pilot-protocol/common/authority" + "github.com/pilot-protocol/common/authorityhttp" + "github.com/pilot-protocol/common/decision" + "github.com/pilot-protocol/pilotprotocol/internal/enterprisecontrol" +) + +const defaultEnrollmentTokenEnvironment = "PILOT_ENROLLMENT_TOKEN" + +type enterpriseAdoptOptions struct { + Endpoint string + OutputDirectory string + TokenEnvironment string + HTTPClient *http.Client +} + +type enterpriseAdoptResult struct { + TenantID string `json:"tenant_id"` + AgentID string `json:"agent_id"` + HarnessID string `json:"harness_id"` + RunID string `json:"run_id"` + ControlPath string `json:"control_path"` + EnrollmentID string `json:"enrollment_id"` + ActionControl bool `json:"action_control"` + FleetControl bool `json:"fleet_control"` + StateSync bool `json:"state_sync"` +} + +type enrolledNodeCredential struct { + Version uint16 `json:"version"` + TenantID string `json:"tenant_id"` + Agent enrolledNodeMaterial `json:"agent"` + RootKeyID string `json:"root_key_id"` + RootPublicKey string `json:"root_public_key"` + Trust authority.TrustBundle `json:"trust"` +} + +type enrolledNodeMaterial struct { + AgentID string `json:"agent_id"` + KeyID string `json:"key_id"` + Seed string `json:"seed"` +} + +func cmdEnterpriseAdopt(args []string) { + home, err := os.UserHomeDir() + if err != nil { + fatalCode("unavailable", "resolve home directory: %v", err) + } + flags := flag.NewFlagSet("enterprise adopt", flag.ContinueOnError) + flags.SetOutput(io.Discard) + options := enterpriseAdoptOptions{OutputDirectory: filepath.Join(home, ".pilot", "managed"), TokenEnvironment: defaultEnrollmentTokenEnvironment} + flags.StringVar(&options.Endpoint, "endpoint", "", "hosted Pilot management origin") + flags.StringVar(&options.OutputDirectory, "output", options.OutputDirectory, "owner-only managed attachment directory") + flags.StringVar(&options.TokenEnvironment, "token-env", options.TokenEnvironment, "environment variable holding the one-time token") + if err := flags.Parse(args); err != nil || flags.NArg() != 0 { + fatalCode("invalid_argument", "enterprise adopt accepts --endpoint, --output, and --token-env") + } + if !enterpriseEnvironmentName(options.TokenEnvironment) { + fatalCode("invalid_argument", "enterprise adopt token environment name is invalid") + } + result, err := adoptEnterpriseNode(context.Background(), options) + if err != nil { + fatalCode("unavailable", "enterprise adopt: %v", err) + } + output(result) +} + +func adoptEnterpriseNode(ctx context.Context, options enterpriseAdoptOptions) (enterpriseAdoptResult, error) { + endpoint, err := normalizedManagedEndpoint(options.Endpoint) + if err != nil { + return enterpriseAdoptResult{}, err + } + token := strings.TrimSpace(os.Getenv(options.TokenEnvironment)) + if token == "" || len(token) > 4096 || strings.ContainsAny(token, "\r\n\x00") { + return enterpriseAdoptResult{}, fmt.Errorf("%s is empty or invalid", options.TokenEnvironment) + } + // The token must not leak into the daemon, harness hooks, or subprocesses + // after the one request that consumes it. + defer os.Unsetenv(options.TokenEnvironment) + client := options.HTTPClient + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + claim, err := claimNodeEnrollment(ctx, client, endpoint, token) + if err != nil { + return enterpriseAdoptResult{}, err + } + credential, root, seed, err := validateEnrolledCredential(claim) + if err != nil { + return enterpriseAdoptResult{}, err + } + policy, err := fetchEnrollmentPolicy(ctx, client, endpoint, claim.TenantID, claim.AgentID) + if err != nil { + return enterpriseAdoptResult{}, err + } + if policy.Bundle.TenantID != claim.TenantID || policy.Bundle.Revision < credential.Trust.PolicyRevision { + return enterpriseAdoptResult{}, fmt.Errorf("active policy does not satisfy the enrolled trust floor") + } + if err := policy.Bundle.Validate(); err != nil { + return enterpriseAdoptResult{}, fmt.Errorf("validate active policy: %w", err) + } + controlPath, err := installEnrolledAttachment(options.OutputDirectory, claim, credential, root, seed, policy.Bundle) + if err != nil { + return enterpriseAdoptResult{}, err + } + return enterpriseAdoptResult{ + TenantID: claim.TenantID, AgentID: claim.AgentID, HarnessID: claim.HarnessID, RunID: claim.RunID, + ControlPath: controlPath, EnrollmentID: claim.EnrollmentID, + ActionControl: claim.Options.ActionControl, FleetControl: claim.Options.FleetControl, StateSync: claim.Options.StateSync, + }, nil +} + +func normalizedManagedEndpoint(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(raw), "/")) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("endpoint must be an HTTPS origin without credentials, path, query, or fragment") + } + return parsed.String(), nil +} + +func claimNodeEnrollment(ctx context.Context, client *http.Client, endpoint, token string) (authorityhttp.NodeEnrollmentClaimResponse, error) { + body, _ := json.Marshal(map[string]string{"token": token}) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/v1/enroll/claim", bytes.NewReader(body)) + if err != nil { + return authorityhttp.NodeEnrollmentClaimResponse{}, err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.Do(request) + if err != nil { + return authorityhttp.NodeEnrollmentClaimResponse{}, fmt.Errorf("claim node identity: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + return authorityhttp.NodeEnrollmentClaimResponse{}, fmt.Errorf("claim node identity returned HTTP %d", response.StatusCode) + } + decoder := json.NewDecoder(io.LimitReader(response.Body, 1<<20)) + decoder.DisallowUnknownFields() + var claim authorityhttp.NodeEnrollmentClaimResponse + if err := decoder.Decode(&claim); err != nil { + return authorityhttp.NodeEnrollmentClaimResponse{}, fmt.Errorf("decode node enrollment: %w", err) + } + if claim.Version != authorityhttp.NodeEnrollmentVersion || claim.EnrollmentID == "" || claim.TenantID == "" || claim.AgentID == "" || claim.HarnessID == "" || claim.RunID == "" || claim.ClaimedAt <= 0 || len(claim.Credential) == 0 || claim.PublicOrigin != endpoint || !claim.Options.FleetControl { + return authorityhttp.NodeEnrollmentClaimResponse{}, fmt.Errorf("node enrollment response is incomplete or mismatched") + } + if _, err := normalizedManagedEndpoint(claim.FederationEndpoint); err != nil { + return authorityhttp.NodeEnrollmentClaimResponse{}, fmt.Errorf("node enrollment federation endpoint: %w", err) + } + return claim, nil +} + +func validateEnrolledCredential(claim authorityhttp.NodeEnrollmentClaimResponse) (enrolledNodeCredential, ed25519.PublicKey, []byte, error) { + decoder := json.NewDecoder(bytes.NewReader(claim.Credential)) + decoder.DisallowUnknownFields() + var credential enrolledNodeCredential + if err := decoder.Decode(&credential); err != nil { + return enrolledNodeCredential{}, nil, nil, fmt.Errorf("decode delegated node credential: %w", err) + } + if credential.Version != 1 || credential.TenantID != claim.TenantID || credential.Agent.AgentID != claim.AgentID || credential.RootKeyID != credential.Trust.RootKeyID || credential.Trust.TenantID != claim.TenantID { + return enrolledNodeCredential{}, nil, nil, fmt.Errorf("delegated node credential binding mismatch") + } + rootBytes, err := hex.DecodeString(credential.RootPublicKey) + if err != nil || len(rootBytes) != ed25519.PublicKeySize { + return enrolledNodeCredential{}, nil, nil, fmt.Errorf("delegated root pin is invalid") + } + root := ed25519.PublicKey(rootBytes) + if err := credential.Trust.Verify(root, time.Now().UTC()); err != nil { + return enrolledNodeCredential{}, nil, nil, fmt.Errorf("verify delegated trust: %w", err) + } + seed, err := hex.DecodeString(credential.Agent.Seed) + if err != nil || len(seed) != ed25519.SeedSize { + return enrolledNodeCredential{}, nil, nil, fmt.Errorf("delegated node seed is invalid") + } + public := ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey) + matched := false + for _, key := range credential.Trust.Keys { + if key.AgentID == claim.AgentID && key.KeyID == credential.Agent.KeyID { + decoded, decodeErr := base64.StdEncoding.DecodeString(key.PublicKey) + matched = decodeErr == nil && bytes.Equal(decoded, public) + break + } + } + if !matched { + return enrolledNodeCredential{}, nil, nil, fmt.Errorf("delegated node key is not active in signed trust") + } + return credential, root, seed, nil +} + +func fetchEnrollmentPolicy(ctx context.Context, client *http.Client, endpoint, tenantID, agentID string) (authorityhttp.ActivePolicyEnvelope, error) { + query := url.Values{"tenant_id": []string{tenantID}, "agent_id": []string{agentID}} + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"/v1/policy-current?"+query.Encode(), nil) + if err != nil { + return authorityhttp.ActivePolicyEnvelope{}, err + } + response, err := client.Do(request) + if err != nil { + return authorityhttp.ActivePolicyEnvelope{}, fmt.Errorf("fetch active policy: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return authorityhttp.ActivePolicyEnvelope{}, fmt.Errorf("active policy returned HTTP %d", response.StatusCode) + } + decoder := json.NewDecoder(io.LimitReader(response.Body, 2<<20)) + decoder.DisallowUnknownFields() + var active authorityhttp.ActivePolicyEnvelope + if err := decoder.Decode(&active); err != nil { + return authorityhttp.ActivePolicyEnvelope{}, fmt.Errorf("decode active policy: %w", err) + } + return active, nil +} + +func installEnrolledAttachment(outputDirectory string, claim authorityhttp.NodeEnrollmentClaimResponse, credential enrolledNodeCredential, root ed25519.PublicKey, seed []byte, policy authority.PolicyBundle) (string, error) { + outputDirectory = filepath.Clean(strings.TrimSpace(outputDirectory)) + if outputDirectory == "" || !filepath.IsAbs(outputDirectory) { + return "", fmt.Errorf("output directory must be absolute") + } + if _, err := os.Lstat(outputDirectory); err == nil { + return "", fmt.Errorf("managed attachment already exists at %s", outputDirectory) + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + parent := filepath.Dir(outputDirectory) + if err := os.MkdirAll(parent, 0o700); err != nil { + return "", err + } + if err := os.Chmod(parent, 0o700); err != nil { + return "", err + } + stage, err := os.MkdirTemp(parent, ".managed-adopt-*") + if err != nil { + return "", err + } + defer os.RemoveAll(stage) + for _, directory := range []string{"runtime/continuations", "state/workflows"} { + if err := os.MkdirAll(filepath.Join(stage, filepath.FromSlash(directory)), 0o700); err != nil { + return "", err + } + } + if err := writeAdoptionJSON(filepath.Join(stage, "trust.json"), credential.Trust); err != nil { + return "", err + } + if err := writeAdoptionJSON(filepath.Join(stage, "policy.json"), policy); err != nil { + return "", err + } + if err := writeAdoptionFile(filepath.Join(stage, "agent.seed"), []byte(base64.StdEncoding.EncodeToString(seed)+"\n")); err != nil { + return "", err + } + if err := writeAdoptionJSON(filepath.Join(stage, "state", "settings.json"), map[string]any{ + "agent_id": claim.AgentID, "display_name": claim.DisplayName, "harness": claim.HarnessID, + "mode": "managed", "source": "hosted-enrollment", "enrollment_id": claim.EnrollmentID, "created_at": claim.ClaimedAt, + }); err != nil { + return "", err + } + config := enterprisecontrol.Config{ + TenantID: claim.TenantID, RootKeyID: credential.RootKeyID, RootPublicKey: base64.StdEncoding.EncodeToString(root), + TrustBundlePath: "trust.json", PolicyBundlePath: "policy.json", + } + if claim.Options.ActionControl { + config.Receipts = &enterprisecontrol.ReceiptConfig{ + AgentID: claim.AgentID, KeyID: credential.Agent.KeyID, SeedPath: "agent.seed", JournalPath: "runtime/receipts.jsonl", + ExportEndpoint: claim.PublicOrigin + "/v1/receipts", ExportAcknowledgementPath: "runtime/receipt-acks.log", ExportIntervalSeconds: 30, ExportBatchSize: 100, + } + config.OutboundDecisions = &enterprisecontrol.OutboundDecisionConfig{ + AuthorityEndpoint: claim.FederationEndpoint, AgentID: claim.AgentID, IntentKeyID: credential.Agent.KeyID, IntentSeedPath: "agent.seed", + Risk: decision.RiskMedium, RequestTimeoutSeconds: 20, Audience: "account:" + claim.TenantID, Purpose: "Govern complete agent tool calls", + ContentLabels: []string{"agent-tool-call", claim.HarnessID}, RetentionClass: "account-default", EvaluatorResidency: "eu-west-1", + } + config.ActionControl = &enterprisecontrol.ActionControlConfig{ + Profile: actionregistry.Profile{Version: actionregistry.SchemaVersion, Mode: actionregistry.ModeManagedEnforce, Actions: managedAdoptionActions()}, + AgentID: claim.AgentID, Risk: decision.RiskMedium, ContinuationDirectory: "runtime/continuations", + } + } + if claim.Options.FleetControl || claim.Options.ActionControl { + config.Rollout = &enterprisecontrol.RolloutConfig{ + AuthorityEndpoint: claim.PublicOrigin, AgentID: claim.AgentID, AcknowledgementKeyID: credential.Agent.KeyID, + AcknowledgementSeedPath: "agent.seed", PollIntervalSeconds: 5, + } + } + if claim.Options.FleetControl { + config.Fleet = &enterprisecontrol.FleetConfig{ + ReportIntervalSeconds: 5, AgentVersion: "pilot-onboarding/" + claim.HarnessID + "/" + claim.RunID, + HarnessID: claim.HarnessID, HarnessVersion: "managed", ConnectorVersion: "pilot-mcp-0.2.0", + StateSyncEnabled: claim.Options.StateSync, StateSyncIntervalSeconds: 2, + } + if claim.Options.StateSync { + config.Fleet.StateDirectory = "state" + } + } + controlPath := filepath.Join(stage, "enterprise-control.json") + if err := writeAdoptionJSON(controlPath, config); err != nil { + return "", err + } + if _, err := enterprisecontrol.Load(controlPath); err != nil { + return "", fmt.Errorf("verify managed attachment: %w", err) + } + if err := os.Rename(stage, outputDirectory); err != nil { + return "", err + } + return filepath.Join(outputDirectory, "enterprise-control.json"), nil +} + +func managedAdoptionActions() []string { + return []string{ + "browser.navigate", "data.export", "data.read", "data.send.binary", "data.send.json", "data.send.text", + "event.publish", "file.delete", "file.read", "file.share", "file.write", "http.request", "process.execute", + "tool.invoke", "trust.accept", "trust.auto_accept", "trust.reject", "trust.request", "trust.revoke", + "wallet.pay", "webhook.send", + } +} + +func writeAdoptionJSON(path string, value any) error { + body, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + body = append(body, '\n') + return writeAdoptionFile(path, body) +} + +func writeAdoptionFile(path string, body []byte) error { + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0o700); err != nil { + return err + } + temporary, err := os.CreateTemp(directory, ".adopt-*") + if err != nil { + return err + } + name := temporary.Name() + defer os.Remove(name) + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(body); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(name, path) +} diff --git a/cmd/pilotctl/enterprise_adopt_test.go b/cmd/pilotctl/enterprise_adopt_test.go new file mode 100644 index 00000000..92835f7b --- /dev/null +++ b/cmd/pilotctl/enterprise_adopt_test.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/pilot-protocol/common/authority" + "github.com/pilot-protocol/common/authorityhttp" + "github.com/pilot-protocol/common/decision" + "github.com/pilot-protocol/common/decisionpolicy" + "github.com/pilot-protocol/pilotprotocol/internal/enterprisecontrol" +) + +func TestAdoptEnterpriseNodeClaimsAndInstallsVerifiedAttachment(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + rootPublic, rootPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + agentPublic, agentPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + authorityPublic, authorityPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + trust := authority.TrustBundle{ + Version: authority.SchemaVersion, TenantID: "tenant-a", Revision: 1, PolicyRevision: 1, RevocationEpoch: 1, + IssuedAt: now.Add(-time.Minute).Unix(), ExpiresAt: now.Add(time.Hour).Unix(), RootKeyID: "root-key", + Keys: []authority.AuthorityKey{ + {KeyID: "agent-key", AgentID: "agent-003", PublicKey: base64.StdEncoding.EncodeToString(agentPublic), Usages: []authority.KeyUsage{authority.UsageIntent, authority.UsageReceipt}, NotBefore: now.Add(-time.Minute).Unix(), ExpiresAt: now.Add(time.Hour).Unix()}, + {KeyID: "authority-key", PublicKey: base64.StdEncoding.EncodeToString(authorityPublic), Usages: []authority.KeyUsage{authority.UsagePolicy, authority.UsageDecision}, NotBefore: now.Add(-time.Minute).Unix(), ExpiresAt: now.Add(time.Hour).Unix()}, + }, + } + if err := trust.Sign(rootPrivate); err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(decisionpolicy.Document{Version: 1, DefaultOutcome: decision.Deny}) + if err != nil { + t.Fatal(err) + } + policy := authority.NewPolicyBundle("tenant-a", 1, 1, now.Add(-time.Minute), now.Add(-time.Minute), now.Add(time.Hour), decisionpolicy.Engine, decisionpolicy.EngineVersion, decisionpolicy.ContentType, "authority-key", payload) + if err := policy.Sign(authorityPrivate); err != nil { + t.Fatal(err) + } + credential, err := json.Marshal(enrolledNodeCredential{ + Version: 1, TenantID: "tenant-a", Agent: enrolledNodeMaterial{AgentID: "agent-003", KeyID: "agent-key", Seed: hex.EncodeToString(agentPrivate.Seed())}, + RootKeyID: "root-key", RootPublicKey: hex.EncodeToString(rootPublic), Trust: trust, + }) + if err != nil { + t.Fatal(err) + } + + const token = "one-time-test-token" + var server *httptest.Server + server = httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v1/enroll/claim": + var body map[string]string + if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body["token"] != token { + http.Error(writer, "bad token", http.StatusBadRequest) + return + } + _ = json.NewEncoder(writer).Encode(authorityhttp.NodeEnrollmentClaimResponse{ + Version: authorityhttp.NodeEnrollmentVersion, EnrollmentID: "enrollment-123", TenantID: "tenant-a", AgentID: "agent-003", + HarnessID: "gemini", DisplayName: "Finance agent", RunID: "run-123456", PublicOrigin: server.URL, FederationEndpoint: server.URL, + Options: authorityhttp.NodeEnrollmentOptions{ActionControl: true, FleetControl: true, StateSync: true}, Credential: credential, ClaimedAt: now.Unix(), + }) + case "/v1/policy-current": + if request.URL.Query().Get("tenant_id") != "tenant-a" || request.URL.Query().Get("agent_id") != "agent-003" { + http.Error(writer, "bad scope", http.StatusBadRequest) + return + } + _ = json.NewEncoder(writer).Encode(authorityhttp.ActivePolicyEnvelope{Bundle: policy}) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + const tokenEnv = "PILOT_TEST_ENROLLMENT_TOKEN" + t.Setenv(tokenEnv, token) + outputDirectory := filepath.Join(t.TempDir(), "managed") + result, err := adoptEnterpriseNode(context.Background(), enterpriseAdoptOptions{ + Endpoint: server.URL, OutputDirectory: outputDirectory, TokenEnvironment: tokenEnv, HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + if result.AgentID != "agent-003" || result.HarnessID != "gemini" || !result.ActionControl || !result.FleetControl || !result.StateSync { + t.Fatalf("adoption result = %+v", result) + } + if os.Getenv(tokenEnv) != "" { + t.Fatal("enrollment token remained in the process environment") + } + if _, err := enterprisecontrol.Load(result.ControlPath); err != nil { + t.Fatalf("installed attachment did not verify: %v", err) + } + if err := filepath.Walk(outputDirectory, func(path string, info os.FileInfo, walkErr error) error { + if walkErr == nil && !info.IsDir() { + body, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + if strings.Contains(string(body), token) { + t.Fatalf("one-time token persisted in %s", path) + } + } + return walkErr + }); err != nil { + t.Fatal(err) + } +} + +func TestNormalizedManagedEndpointRejectsNonOriginInputs(t *testing.T) { + for _, input := range []string{"http://management.example", "https://user@management.example", "https://management.example/path", "https://management.example?tenant=a"} { + if _, err := normalizedManagedEndpoint(input); err == nil { + t.Fatalf("accepted %q", input) + } + } +} diff --git a/cmd/pilotctl/enterprise_hook.go b/cmd/pilotctl/enterprise_hook.go new file mode 100644 index 00000000..a1099b0a --- /dev/null +++ b/cmd/pilotctl/enterprise_hook.go @@ -0,0 +1,410 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + "unicode/utf8" + + "github.com/pilot-protocol/common/actionhook" + "github.com/pilot-protocol/common/decision" + "github.com/pilot-protocol/pilotprotocol/internal/enterprisecontrol" +) + +const ( + externalHookVersion uint16 = 1 + maxExternalHookContent = 16 << 20 + externalHookAttemptVersion uint16 = 1 +) + +// enterpriseHookRequest is the harness-neutral process boundary. Harness +// adapters translate their native event into this schema; the configured +// action/resource mapping remains visible in traces and policy evaluation. +// ContentBase64 is the exact JSON or byte representation of the tool request +// or result. Labels, retention, purpose, and evaluator residency are not +// caller-controlled: Runtime.NewOutboundFederated* supplies those values from +// the protected attachment. +type enterpriseHookRequest struct { + Version uint16 `json:"version"` + AttemptKey string `json:"attempt_key"` + Action string `json:"action,omitempty"` + Resource string `json:"resource,omitempty"` + AdapterID string `json:"adapter_id,omitempty"` + ContentType string `json:"content_type,omitempty"` + Filename string `json:"filename,omitempty"` + ContentBase64 string `json:"content_base64,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` + ResumeToken string `json:"resume_token,omitempty"` + Status string `json:"status,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +type enterpriseHookResponse struct { + Version uint16 `json:"version"` + Attached bool `json:"attached"` + Execute bool `json:"execute"` + Outcome decision.Outcome `json:"outcome"` + Reasons []string `json:"reasons,omitempty"` + Reference actionhook.DecisionReference `json:"reference,omitempty"` + ObserveOnly bool `json:"observe_only,omitempty"` + AttemptID string `json:"attempt_id,omitempty"` + Reported bool `json:"reported,omitempty"` + Warning string `json:"warning,omitempty"` +} + +type persistedExternalHookAttempt struct { + Version uint16 `json:"version"` + AttemptID string `json:"attempt_id"` + CreatedAt int64 `json:"created_at"` + Attempt enterprisecontrol.ExternalActionAttempt `json:"attempt"` + Observed *actionhook.ObservedResult `json:"observed,omitempty"` +} + +func cmdEnterpriseHook(args []string) { + if len(args) == 0 || (args[0] != "pre" && args[0] != "post") { + fatalHint("invalid_argument", "available: pilotctl --json enterprise hook pre | post", "missing or invalid enterprise hook phase") + } + phase := args[0] + flags := flag.NewFlagSet("enterprise hook "+phase, flag.ContinueOnError) + flags.SetOutput(io.Discard) + controlPath := "" + flags.StringVar(&controlPath, "control", "", "enterprise-control attachment path") + if err := flags.Parse(args[1:]); err != nil || flags.NArg() != 0 { + fatalCode("invalid_argument", "enterprise hook %s accepts only --control", phase) + } + if strings.TrimSpace(controlPath) == "" { + controlPath = strings.TrimSpace(os.Getenv("PILOT_ENTERPRISE_CONTROL")) + } + request, err := decodeEnterpriseHookRequest(os.Stdin) + if err != nil { + fatalCode("invalid_argument", "enterprise hook %s request: %v", phase, err) + } + if controlPath == "" { + // The compatibility invariant: installing a harness adapter does not + // opt an existing/open agent into governance. + output(enterpriseHookResponse{Version: externalHookVersion, Execute: true, Outcome: decision.Allow}) + return + } + runtime, err := enterprisecontrol.Load(controlPath) + if err != nil { + fatalCode("unavailable", "enterprise hook %s control attachment: %v", phase, err) + } + if phase == "pre" { + response, err := executeEnterprisePreHook(context.Background(), controlPath, runtime, request) + if err != nil { + fatalCode("unavailable", "enterprise hook pre: %v", err) + } + output(response) + return + } + response, err := executeEnterprisePostHook(context.Background(), controlPath, runtime, request) + if err != nil { + fatalCode("unavailable", "enterprise hook post: %v", err) + } + output(response) +} + +func decodeEnterpriseHookRequest(reader io.Reader) (enterpriseHookRequest, error) { + limited := io.LimitReader(reader, maxExternalHookContent+(1<<20)) + decoder := json.NewDecoder(limited) + decoder.DisallowUnknownFields() + var request enterpriseHookRequest + if err := decoder.Decode(&request); err != nil { + return enterpriseHookRequest{}, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return enterpriseHookRequest{}, fmt.Errorf("multiple JSON values are not allowed") + } + return enterpriseHookRequest{}, err + } + if request.Version != externalHookVersion { + return enterpriseHookRequest{}, fmt.Errorf("unsupported version %d", request.Version) + } + if strings.TrimSpace(request.AttemptKey) == "" || len(request.AttemptKey) > 1024 || !utf8.ValidString(request.AttemptKey) { + return enterpriseHookRequest{}, fmt.Errorf("attempt_key is required and must be at most 1024 UTF-8 bytes") + } + return request, nil +} + +func executeEnterprisePreHook(ctx context.Context, controlPath string, runtime *enterprisecontrol.Runtime, request enterpriseHookRequest) (enterpriseHookResponse, error) { + hook := runtime.ActionHook() + if hook == nil { + return enterpriseHookResponse{Version: externalHookVersion, Execute: true, Outcome: decision.Allow}, nil + } + if request.Action == "" || request.Resource == "" || request.AdapterID == "" { + return enterpriseHookResponse{}, fmt.Errorf("action, resource, and adapter_id are required") + } + body, err := decodeEnterpriseHookContent(request.ContentBase64) + if err != nil { + return enterpriseHookResponse{}, err + } + contentType := strings.TrimSpace(request.ContentType) + if contentType == "" { + contentType = "application/json" + } + now := time.Now().UTC() + var envelope actionhook.Envelope + content, contentErr := runtime.NewOutboundFederatedContent(contentType, request.Filename, body) + if contentErr == nil { + envelope, err = actionhook.NewFederatedEnvelope(request.Action, request.Resource, request.AdapterID, content, request.Attributes, now) + } else { + envelope, err = actionhook.NewEnvelope(request.Action, request.Resource, decision.HashPayload(body), request.AdapterID, request.Attributes, now) + } + if err != nil { + return enterpriseHookResponse{}, err + } + envelope.ResumeToken = request.ResumeToken + preflight, err := hook.BeforeAction(ctx, envelope) + if err != nil { + return enterpriseHookResponse{}, err + } + response := enterpriseHookResponse{ + Version: externalHookVersion, Attached: true, Execute: preflight.RequireUnconstrained() == nil, + Outcome: preflight.Outcome, Reasons: append([]string(nil), preflight.Reasons...), + Reference: preflight.Reference, ObserveOnly: preflight.ObserveOnly, + } + record, persist, err := runtime.ExportExternalActionAttempt(envelope, preflight) + if err != nil { + // An approved continuation is claimed before its execution decision is + // returned. If the process boundary cannot durably retain that state, + // finish the claim as failed and report evidence instead of leaving an + // indefinitely executing continuation. The side effect has not run. + if response.Execute { + _ = hook.AfterAction(ctx, envelope, preflight, actionhook.ObservedResult{ + Status: actionhook.StatusFailed, ObservedAt: now.Unix(), ErrorCode: "hook_state_persistence_failed", + Attributes: map[string]string{"hook_phase": "pre"}, + }) + } + return enterpriseHookResponse{}, err + } + if !persist { + return response, nil + } + persisted := persistedExternalHookAttempt{ + Version: externalHookAttemptVersion, AttemptID: externalHookAttemptID(request.AttemptKey), + CreatedAt: now.Unix(), Attempt: record, + } + response.AttemptID = persisted.AttemptID + if !response.Execute { + observed := actionhook.ObservedResult{ObservedAt: now.Unix(), Attributes: map[string]string{"hook_phase": "pre"}} + switch preflight.Outcome { + case decision.ApprovalRequired: + observed.Status = actionhook.StatusApprovalPending + case decision.Deny, decision.Constrain: + observed.Status = actionhook.StatusDenied + default: + observed.Status = actionhook.StatusSkipped + } + persisted.Observed = &observed + } + if err := writeExternalHookAttempt(controlPath, persisted); err != nil { + return enterpriseHookResponse{}, err + } + if persisted.Observed != nil { + restoredEnvelope, restoredPreflight, err := runtime.ImportExternalActionAttempt(record) + if err != nil { + return enterpriseHookResponse{}, err + } + if err := hook.AfterAction(ctx, restoredEnvelope, restoredPreflight, *persisted.Observed); err != nil { + response.Warning = "decision enforced; evidence report is queued for retry" + return response, nil + } + if err := removeExternalHookAttempt(controlPath, persisted.AttemptID); err != nil { + response.Warning = "decision enforced and reported; local attempt cleanup failed" + return response, nil + } + response.Reported = true + } + return response, nil +} + +func executeEnterprisePostHook(ctx context.Context, controlPath string, runtime *enterprisecontrol.Runtime, request enterpriseHookRequest) (enterpriseHookResponse, error) { + attemptID := externalHookAttemptID(request.AttemptKey) + persisted, err := readExternalHookAttempt(controlPath, attemptID) + if err != nil { + return enterpriseHookResponse{}, err + } + envelope, preflight, err := runtime.ImportExternalActionAttempt(persisted.Attempt) + if err != nil { + return enterpriseHookResponse{}, err + } + var observed actionhook.ObservedResult + if persisted.Observed != nil { + // The first post-hook invocation durably fixes the signed observation. + // Reuse it byte-for-byte on outbox retries so result IDs, receipts, and + // usage units remain idempotent even after process or authority restarts. + observed = *persisted.Observed + switch observed.Status { + case actionhook.StatusSucceeded, actionhook.StatusFailed, actionhook.StatusSkipped: + default: + return enterpriseHookResponse{}, fmt.Errorf("hook attempt did not execute and cannot accept a post-hook") + } + } else { + observedStatus, err := externalObservedStatus(request.Status) + if err != nil { + return enterpriseHookResponse{}, err + } + observed = actionhook.ObservedResult{ + Status: observedStatus, ObservedAt: time.Now().UTC().Unix(), ErrorCode: request.ErrorCode, + Attributes: request.Attributes, + } + responseBody, err := decodeEnterpriseHookContent(request.ContentBase64) + if err != nil { + return enterpriseHookResponse{}, err + } + if request.ContentBase64 != "" { + contentType := strings.TrimSpace(request.ContentType) + if contentType == "" { + contentType = "application/json" + } + content, err := runtime.NewOutboundFederatedResponseContent(contentType, request.Filename, responseBody) + if err != nil { + return enterpriseHookResponse{}, err + } + observed.FederatedContent = &content + } + persisted.Observed = &observed + if err := writeExternalHookAttempt(controlPath, persisted); err != nil { + return enterpriseHookResponse{}, fmt.Errorf("persist post-hook observation before reporting: %w", err) + } + } + if err := runtime.AfterAction(ctx, envelope, preflight, observed); err != nil { + return enterpriseHookResponse{}, err + } + if err := removeExternalHookAttempt(controlPath, attemptID); err != nil { + return enterpriseHookResponse{}, err + } + return enterpriseHookResponse{ + Version: externalHookVersion, Attached: true, Execute: true, Outcome: preflight.Outcome, + Reference: preflight.Reference, AttemptID: attemptID, Reported: true, + }, nil +} + +func decodeEnterpriseHookContent(encoded string) ([]byte, error) { + if encoded == "" { + return []byte{}, nil + } + body, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("content_base64 is invalid") + } + if len(body) > maxExternalHookContent { + return nil, fmt.Errorf("hook content exceeds %d bytes", maxExternalHookContent) + } + return body, nil +} + +func externalObservedStatus(value string) (actionhook.ObservedStatus, error) { + switch strings.TrimSpace(value) { + case "", "succeeded": + return actionhook.StatusSucceeded, nil + case "failed": + return actionhook.StatusFailed, nil + case "skipped": + return actionhook.StatusSkipped, nil + default: + return "", fmt.Errorf("status must be succeeded, failed, or skipped") + } +} + +func externalHookAttemptID(key string) string { + hash := sha256.Sum256([]byte(key)) + return hex.EncodeToString(hash[:]) +} + +func externalHookAttemptDirectory(controlPath string) string { + return filepath.Join(filepath.Dir(controlPath), ".external-hook-attempts") +} + +func externalHookAttemptPath(controlPath, attemptID string) string { + return filepath.Join(externalHookAttemptDirectory(controlPath), attemptID+".json") +} + +func writeExternalHookAttempt(controlPath string, attempt persistedExternalHookAttempt) error { + directory := externalHookAttemptDirectory(controlPath) + if err := os.MkdirAll(directory, 0o700); err != nil { + return fmt.Errorf("create hook attempt directory: %w", err) + } + info, err := os.Lstat(directory) + if err != nil || !info.IsDir() || info.Mode()&0o077 != 0 { + return fmt.Errorf("hook attempt directory must be an owner-only directory") + } + body, err := json.Marshal(attempt) + if err != nil { + return fmt.Errorf("encode hook attempt: %w", err) + } + temporary, err := os.CreateTemp(directory, ".attempt-*") + if err != nil { + return fmt.Errorf("create hook attempt: %w", err) + } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(body); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryName, externalHookAttemptPath(controlPath, attempt.AttemptID)); err != nil { + return fmt.Errorf("commit hook attempt: %w", err) + } + return nil +} + +func readExternalHookAttempt(controlPath, attemptID string) (persistedExternalHookAttempt, error) { + path := externalHookAttemptPath(controlPath, attemptID) + info, err := os.Lstat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return persistedExternalHookAttempt{}, fmt.Errorf("hook attempt %s was not found", attemptID) + } + return persistedExternalHookAttempt{}, err + } + if !info.Mode().IsRegular() || info.Mode()&0o077 != 0 { + return persistedExternalHookAttempt{}, fmt.Errorf("hook attempt must be an owner-only regular file") + } + file, err := os.Open(path) + if err != nil { + return persistedExternalHookAttempt{}, err + } + defer file.Close() + decoder := json.NewDecoder(io.LimitReader(file, 2<<20)) + decoder.DisallowUnknownFields() + var attempt persistedExternalHookAttempt + if err := decoder.Decode(&attempt); err != nil { + return persistedExternalHookAttempt{}, fmt.Errorf("decode hook attempt: %w", err) + } + if attempt.Version != externalHookAttemptVersion || attempt.AttemptID != attemptID || attempt.CreatedAt <= 0 { + return persistedExternalHookAttempt{}, fmt.Errorf("hook attempt record is invalid") + } + return attempt, nil +} + +func removeExternalHookAttempt(controlPath, attemptID string) error { + if err := os.Remove(externalHookAttemptPath(controlPath, attemptID)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} diff --git a/cmd/pilotctl/main.go b/cmd/pilotctl/main.go index d95d0f82..58fa017e 100644 --- a/cmd/pilotctl/main.go +++ b/cmd/pilotctl/main.go @@ -1055,6 +1055,7 @@ Flags: --wait how long to wait for daemon to become ready (default: 15s) --motd-feed-url message-of-the-day feed (empty to disable; env PILOT_MOTD_URL) --motd-interval message-of-the-day poll interval (default: 15m) + --enterprise-control owner-only managed control attachment `, "daemon stop": `Usage: pilotctl daemon stop @@ -2737,6 +2738,12 @@ func buildDaemonArgs(args []string) (daemonArgs []string, socketPath string, adm } } trustAutoApprove := flagBool(flags, "trust-auto-approve") + enterpriseControl := flagString(flags, "enterprise-control", "") + if enterpriseControl == "" { + if value, ok := cfg["enterprise_control"].(string); ok { + enterpriseControl = strings.TrimSpace(value) + } + } daemonArgs = []string{ "--registry", registryAddr, @@ -2775,6 +2782,9 @@ func buildDaemonArgs(args []string) (daemonArgs []string, socketPath string, adm if trustAutoApprove { daemonArgs = append(daemonArgs, "--trust-auto-approve") } + if enterpriseControl != "" { + daemonArgs = append(daemonArgs, "--enterprise-control", enterpriseControl) + } return daemonArgs, socketPath, adminToken } diff --git a/internal/enterprisecontrol/action_hook.go b/internal/enterprisecontrol/action_hook.go index 3b61b34f..a2538670 100644 --- a/internal/enterprisecontrol/action_hook.go +++ b/internal/enterprisecontrol/action_hook.go @@ -8,6 +8,7 @@ import ( "crypto/ed25519" "errors" "fmt" + "slices" "time" "github.com/pilot-protocol/common/actioncontinuation" @@ -29,6 +30,32 @@ type actionHookState struct { continuationLease string } +// ExternalActionAttempt is the restart-safe, content-free portion of a +// managed action preflight. Native agent harnesses invoke pre- and post-hooks +// in separate processes, so the opaque actionHookState cannot remain only in +// memory. The exact request and response bodies are deliberately excluded: +// they travel to the hosted federation endpoint during the corresponding +// hook and are never written to this record. +// +// The signed Intent and Decision are retained so ImportExternalActionAttempt +// can authenticate the record against the locally pinned trust bundle before +// it is allowed to produce receipts, activity, or a federation result. +type ExternalActionAttempt struct { + Version uint16 `json:"version"` + Envelope actionhook.Envelope `json:"envelope"` + Preflight actionhook.Preflight `json:"preflight"` + Selected bool `json:"selected"` + Managed bool `json:"managed"` + Federated bool `json:"federated"` + ExchangeID string `json:"exchange_id,omitempty"` + Intent decision.Intent `json:"intent"` + Decision decision.Decision `json:"decision"` + ContinuationID string `json:"continuation_id,omitempty"` + ContinuationLease string `json:"continuation_lease,omitempty"` +} + +const externalActionAttemptVersion uint16 = 1 + // ActionHook returns this attachment as a universal hook only when an // operator explicitly selected a non-off profile. A nil return is the hard // compatibility boundary used by unmanaged nodes. @@ -51,6 +78,99 @@ func (runtime *Runtime) ActionArtifacts(preflight actionhook.Preflight) (decisio return state.intent, state.result, true } +// ExportExternalActionAttempt converts process-local hook state into a +// bounded record suitable for a protected local outbox. It returns false for +// unselected and local/observe-only actions because AfterAction has no remote +// evidence to complete for those cases. +func (runtime *Runtime) ExportExternalActionAttempt(envelope actionhook.Envelope, preflight actionhook.Preflight) (ExternalActionAttempt, bool, error) { + if runtime == nil { + return ExternalActionAttempt{}, false, fmt.Errorf("enterprise control: runtime is nil") + } + if err := envelope.Validate(); err != nil { + return ExternalActionAttempt{}, false, err + } + state, ok := preflight.State.(actionHookState) + if !ok || !state.selected || !state.managed { + return ExternalActionAttempt{}, false, nil + } + // Content and the adapter-local resume token never cross the persistence + // boundary. PayloadHash still binds the disclosure that was evaluated. + envelope.FederatedContent = nil + envelope.ResumeToken = "" + preflight.State = nil + record := ExternalActionAttempt{ + Version: externalActionAttemptVersion, Envelope: envelope, Preflight: preflight, + Selected: state.selected, Managed: state.managed, Federated: state.federated, + ExchangeID: state.exchangeID, Intent: state.intent, Decision: state.result, + ContinuationID: state.continuationID, ContinuationLease: state.continuationLease, + } + if _, _, err := runtime.ImportExternalActionAttempt(record); err != nil { + return ExternalActionAttempt{}, false, err + } + return record, true, nil +} + +// ImportExternalActionAttempt reconstructs the opaque state needed by +// AfterAction. It authenticates both signed wire objects at their historical +// issuance times, then checks their tenant, agent, action, resource, and +// payload bindings against the persisted envelope. This prevents a corrupted +// outbox record from manufacturing evidence for another action. +func (runtime *Runtime) ImportExternalActionAttempt(record ExternalActionAttempt) (actionhook.Envelope, actionhook.Preflight, error) { + if runtime == nil || runtime.trust == nil { + return actionhook.Envelope{}, actionhook.Preflight{}, fmt.Errorf("enterprise control: runtime is not initialized") + } + if record.Version != externalActionAttemptVersion || !record.Selected || !record.Managed { + return actionhook.Envelope{}, actionhook.Preflight{}, fmt.Errorf("enterprise control: invalid external action attempt") + } + if record.Envelope.FederatedContent != nil || record.Envelope.ResumeToken != "" { + return actionhook.Envelope{}, actionhook.Preflight{}, fmt.Errorf("enterprise control: external action attempt contains process-local content") + } + if err := record.Envelope.Validate(); err != nil { + return actionhook.Envelope{}, actionhook.Preflight{}, err + } + if err := record.Intent.Validate(); err != nil { + return actionhook.Envelope{}, actionhook.Preflight{}, fmt.Errorf("enterprise control: external intent: %w", err) + } + if err := record.Decision.Validate(); err != nil { + return actionhook.Envelope{}, actionhook.Preflight{}, fmt.Errorf("enterprise control: external decision: %w", err) + } + runtime.mu.Lock() + tenantID, agentID := runtime.tenantID, runtime.actionAgentID + runtime.mu.Unlock() + if record.Intent.TenantID != tenantID || record.Intent.AgentID != agentID || + record.Intent.Action != record.Envelope.Action || record.Intent.Resource != record.Envelope.Resource || + record.Intent.PayloadHash != record.Envelope.PayloadHash { + return actionhook.Envelope{}, actionhook.Preflight{}, fmt.Errorf("enterprise control: external action attempt binding mismatch") + } + intentAt := time.Unix(record.Intent.IssuedAt, 0).UTC() + intentKey, err := runtime.trust.IntentKeyAt(record.Intent.TenantID, record.Intent.AgentID, record.Intent.KeyID, intentAt) + if err != nil || record.Intent.Verify(intentKey, intentAt) != nil { + return actionhook.Envelope{}, actionhook.Preflight{}, fmt.Errorf("enterprise control: external intent signature is invalid") + } + decisionAt := time.Unix(record.Decision.IssuedAt, 0).UTC() + decisionKey, err := runtime.trust.DecisionKeyAt(record.Decision.TenantID, record.Decision.KeyID, decisionAt) + if err != nil { + return actionhook.Envelope{}, actionhook.Preflight{}, fmt.Errorf("enterprise control: external decision key: %w", err) + } + if err := record.Decision.VerifyFor(record.Intent, decisionKey, decisionAt); err != nil { + return actionhook.Envelope{}, actionhook.Preflight{}, fmt.Errorf("enterprise control: external decision signature: %w", err) + } + if record.Preflight.Outcome != record.Decision.Outcome || record.Preflight.ObserveOnly || + record.Preflight.Reference.IntentID != record.Intent.ID || record.Preflight.Reference.DecisionID != record.Decision.ID || + record.Preflight.Reference.ExchangeID != record.ExchangeID { + return actionhook.Envelope{}, actionhook.Preflight{}, fmt.Errorf("enterprise control: external preflight binding mismatch") + } + if record.Federated && record.ExchangeID == "" { + return actionhook.Envelope{}, actionhook.Preflight{}, fmt.Errorf("enterprise control: federated external attempt is missing an exchange") + } + record.Preflight.State = actionHookState{ + selected: true, managed: true, federated: record.Federated, + exchangeID: record.ExchangeID, intent: record.Intent, result: record.Decision, + continuationID: record.ContinuationID, continuationLease: record.ContinuationLease, + } + return record.Envelope, record.Preflight, nil +} + // BeforeAction evaluates only explicitly selected actions. Local enforcement // uses the signed policy bundle already installed on the node; managed // enforcement obtains and verifies a fresh signed authority Decision. Observe @@ -246,7 +366,7 @@ func (runtime *Runtime) resumeAction(ctx context.Context, envelope actionhook.En _, _ = store.Finish(context.Background(), claimed.ID, lease, false, "workflow_execution_failed") return actionhook.Preflight{}, true, fmt.Errorf("enterprise control: execute approved workflow: %w", err) } - if err := runtime.enforcer.Verify(ctx, intent, result); err != nil { + if err := runtime.verifyApprovedWorkflowDecision(ctx, intent, result, workflow); err != nil { _, _ = store.Finish(context.Background(), claimed.ID, lease, false, "decision_verification_failed") return actionhook.Preflight{}, true, fmt.Errorf("enterprise control: verify approved workflow decision: %w", err) } @@ -254,7 +374,7 @@ func (runtime *Runtime) resumeAction(ctx context.Context, envelope actionhook.En Outcome: result.Outcome, Reasons: append([]string(nil), result.Reasons...), Constraints: append([]decision.Constraint(nil), result.Constraints...), Reference: actionhook.DecisionReference{ IntentID: intent.ID, DecisionID: result.ID, PolicyRevision: result.PolicyRevision, ProviderID: result.ProviderID, - ApprovalTransaction: continuation.ApprovalTransaction, ApprovalExpiresAt: continuation.ExpiresAt, + ExchangeID: reference.ExchangeID, ApprovalTransaction: continuation.ApprovalTransaction, ApprovalExpiresAt: continuation.ExpiresAt, }, State: actionHookState{ selected: true, managed: true, federated: reference.ExchangeID != "", exchangeID: reference.ExchangeID, intent: intent, result: result, @@ -263,6 +383,90 @@ func (runtime *Runtime) resumeAction(ctx context.Context, envelope actionhook.En }, true, nil } +// verifyApprovedWorkflowDecision is the only path on which an `allow` or +// `constrain` result may follow a local `approval_required` ceiling. The +// ordinary Enforcer.Verify correctly rejects that as an authority expansion; +// here the expansion is instead justified by the exact, locally persisted +// transaction ID checked by workflowMatchesContinuation and by the complete +// signed approval chain below. This method never accepts a free-standing +// provider allow as a substitute for that chain. +func (runtime *Runtime) verifyApprovedWorkflowDecision(ctx context.Context, intent decision.Intent, result decision.Decision, workflow decisionhttp.WorkflowRecord) error { + if runtime == nil || runtime.trust == nil || workflow.Certificate == nil { + return fmt.Errorf("approved workflow verification is unavailable") + } + now := time.Now().UTC() + if runtime.enforcer != nil && runtime.enforcer.Now != nil { + now = runtime.enforcer.Now().UTC() + } + intentKey, err := runtime.trust.IntentKey(ctx, intent.TenantID, intent.AgentID, intent.KeyID) + if err != nil { + return fmt.Errorf("resolve execution intent key: %w", err) + } + if err := intent.Verify(intentKey, now); err != nil { + return fmt.Errorf("execution intent: %w", err) + } + resultKey, err := runtime.trust.DecisionKey(ctx, intent.TenantID, result.KeyID) + if err != nil { + return fmt.Errorf("resolve workflow decision key: %w", err) + } + if err := result.VerifyFor(intent, resultKey, now); err != nil { + return fmt.Errorf("workflow decision: %w", err) + } + minimumPolicy, minimumRevocation, err := runtime.trust.MinimumState(ctx, intent.TenantID) + if err != nil { + return fmt.Errorf("resolve minimum authority state: %w", err) + } + if result.PolicyRevision < minimumPolicy || workflow.Certificate.PolicyRevision < minimumPolicy { + return fmt.Errorf("stale approved policy revision") + } + if result.RevocationEpoch < minimumRevocation || workflow.Certificate.RevocationEpoch < minimumRevocation { + return fmt.Errorf("stale approved revocation epoch") + } + + transactionKey, err := runtime.trust.DecisionKeyAt( + workflow.Transaction.TenantID, workflow.Transaction.KeyID, time.Unix(workflow.Transaction.CreatedAt, 0), + ) + if err != nil { + return fmt.Errorf("resolve approval transaction key: %w", err) + } + if err := workflow.Transaction.Verify(transactionKey, now); err != nil { + return fmt.Errorf("approval transaction: %w", err) + } + if err := workflow.Transaction.MatchesIntent(intent); err != nil { + return err + } + approvalKeys := make(map[string]ed25519.PublicKey, len(workflow.Votes)) + for _, vote := range workflow.Votes { + key, keyErr := runtime.trust.ApprovalKeyAt(vote.TenantID, vote.KeyID, time.Unix(vote.IssuedAt, 0)) + if keyErr != nil { + return fmt.Errorf("resolve historical approval key %s: %w", vote.KeyID, keyErr) + } + approvalKeys[vote.KeyID] = key + } + certificate := *workflow.Certificate + certificateKey, err := runtime.trust.DecisionKeyAt(certificate.TenantID, certificate.KeyID, time.Unix(certificate.FinalizedAt, 0)) + if err != nil { + return fmt.Errorf("resolve approval certificate key: %w", err) + } + if err := certificate.VerifyFor(workflow.Transaction, workflow.Votes, approvalKeys, certificateKey, now); err != nil { + return fmt.Errorf("approval certificate: %w", err) + } + if intent.IssuedAt < certificate.FinalizedAt-int64(decision.MaxClockSkew/time.Second) || intent.ExpiresAt > certificate.ExpiresAt { + return fmt.Errorf("execution intent is outside the approval certificate window") + } + certificateHash, err := certificate.Hash() + if err != nil { + return err + } + if result.Outcome != certificate.Outcome || !slices.Equal(result.Constraints, certificate.Constraints) || + result.PolicyRevision != certificate.PolicyRevision || result.RevocationEpoch != certificate.RevocationEpoch || + result.ProviderID != certificate.ProviderID || result.ExpiresAt > certificate.ExpiresAt || + len(result.Reasons) != 1 || result.Reasons[0] != "workflow:"+certificateHash { + return fmt.Errorf("workflow decision does not match the approval certificate") + } + return nil +} + func workflowMatchesContinuation(workflow decisionhttp.WorkflowRecord, continuation actioncontinuation.Record) bool { transaction := workflow.Transaction return transaction.ID == continuation.ApprovalTransaction && transaction.TenantID == continuation.TenantID && diff --git a/internal/enterprisecontrol/action_hook_external_test.go b/internal/enterprisecontrol/action_hook_external_test.go new file mode 100644 index 00000000..3a9f58e0 --- /dev/null +++ b/internal/enterprisecontrol/action_hook_external_test.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package enterprisecontrol + +import ( + "strings" + "testing" + "time" + + "github.com/pilot-protocol/common/actionhook" + "github.com/pilot-protocol/common/decision" +) + +func TestExternalActionAttemptRoundTripsAndRejectsTampering(t *testing.T) { + fixture := newControlFixture(t) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + runtime.actionAgentID = "sender-a" + payloadHash := decision.HashPayload([]byte(`{"command":"rm -rf /tmp/example"}`)) + envelope, err := actionhook.NewEnvelope("process.execute", "rm", payloadHash, "harness.claude", map[string]string{"harness": "claude"}, fixture.now) + if err != nil { + t.Fatal(err) + } + intent := decision.Intent{ + Version: decision.SchemaVersion, ID: "intent-external-1", TenantID: "tenant-a", AgentID: "sender-a", + Action: envelope.Action, Resource: envelope.Resource, PayloadHash: envelope.PayloadHash, Risk: decision.RiskHigh, + IssuedAt: fixture.now.Unix(), ExpiresAt: fixture.now.Add(2 * time.Minute).Unix(), Nonce: strings.Repeat("a", 32), KeyID: "sender-key", + } + if err := intent.Sign(fixture.intentPrivate); err != nil { + t.Fatal(err) + } + intentHash, err := intent.Hash() + if err != nil { + t.Fatal(err) + } + result := decision.Decision{ + Version: decision.SchemaVersion, ID: "decision-external-1", IntentHash: intentHash, + TenantID: "tenant-a", AgentID: "sender-a", Outcome: decision.Allow, + PolicyRevision: 1, RevocationEpoch: 1, ProviderID: "pilot-managed", IssuedAt: fixture.now.Unix(), + ExpiresAt: fixture.now.Add(time.Minute).Unix(), KeyID: "authority-key", + } + if err := result.Sign(fixture.decisionPrivate); err != nil { + t.Fatal(err) + } + preflight := actionhook.Preflight{ + Outcome: decision.Allow, + Reference: actionhook.DecisionReference{IntentID: intent.ID, DecisionID: result.ID, PolicyRevision: 1, ProviderID: result.ProviderID}, + State: actionHookState{selected: true, managed: true, intent: intent, result: result}, + } + record, persist, err := runtime.ExportExternalActionAttempt(envelope, preflight) + if err != nil || !persist { + t.Fatalf("export persist=%v err=%v", persist, err) + } + restoredEnvelope, restoredPreflight, err := runtime.ImportExternalActionAttempt(record) + if err != nil { + t.Fatal(err) + } + if restoredEnvelope.ID != envelope.ID || restoredPreflight.Outcome != decision.Allow { + t.Fatalf("restored envelope=%+v preflight=%+v", restoredEnvelope, restoredPreflight) + } + state, ok := restoredPreflight.State.(actionHookState) + if !ok || state.intent.ID != intent.ID || state.result.ID != result.ID { + t.Fatalf("restored state=%+v", restoredPreflight.State) + } + + tampered := record + tampered.Envelope.Resource = "sudo" + if _, _, err := runtime.ImportExternalActionAttempt(tampered); err == nil || !strings.Contains(err.Error(), "binding mismatch") { + t.Fatalf("tampered record error=%v", err) + } + tampered = record + tampered.Decision.Outcome = decision.Deny + if _, _, err := runtime.ImportExternalActionAttempt(tampered); err == nil || !strings.Contains(err.Error(), "signature") { + t.Fatalf("tampered decision error=%v", err) + } +} + +func TestExternalActionAttemptDoesNotPersistLocalOrObserveOnlyState(t *testing.T) { + fixture := newControlFixture(t) + runtime, err := Load(fixture.path) + if err != nil { + t.Fatal(err) + } + envelope, err := actionhook.NewEnvelope("file.read", "/tmp/example", decision.HashPayload([]byte("metadata")), "harness.test", nil, time.Now()) + if err != nil { + t.Fatal(err) + } + _, persist, err := runtime.ExportExternalActionAttempt(envelope, actionhook.Preflight{ + Outcome: decision.Allow, ObserveOnly: true, State: actionHookState{selected: true}, + }) + if err != nil || persist { + t.Fatalf("persist=%v err=%v", persist, err) + } +} diff --git a/internal/enterprisecontrol/control.go b/internal/enterprisecontrol/control.go index b116b69a..6a5159f0 100644 --- a/internal/enterprisecontrol/control.go +++ b/internal/enterprisecontrol/control.go @@ -157,6 +157,10 @@ type RolloutConfig struct { // signed allowlisted commands; it never opens an inbound remote shell. type FleetConfig struct { ReportIntervalSeconds int64 `json:"report_interval_seconds,omitempty"` + AgentVersion string `json:"agent_version,omitempty"` + HarnessID string `json:"harness_id,omitempty"` + HarnessVersion string `json:"harness_version,omitempty"` + ConnectorVersion string `json:"connector_version,omitempty"` StateSyncEnabled bool `json:"state_sync_enabled,omitempty"` StateDirectory string `json:"state_directory,omitempty"` StateSyncIntervalSeconds int64 `json:"state_sync_interval_seconds,omitempty"` @@ -166,15 +170,18 @@ type FleetConfig struct { // operator console. It excludes endpoint addresses, identities, payloads, // prompts, local paths, and environment values. type FleetNodeStatus struct { - NodeID uint32 - AgentVersion string - UptimeSeconds uint64 - Connections uint32 - Peers uint32 - EncryptedPeers uint32 - BytesSent uint64 - BytesReceived uint64 - PolicyRevision uint64 + NodeID uint32 + AgentVersion string + HarnessID string + HarnessVersion string + ConnectorVersion string + UptimeSeconds uint64 + Connections uint32 + Peers uint32 + EncryptedPeers uint32 + BytesSent uint64 + BytesReceived uint64 + PolicyRevision uint64 } // OutboundDecisionConfig lets a local sender request a short-lived signed @@ -255,6 +262,10 @@ type Runtime struct { rolloutPrivate ed25519.PrivateKey rolloutInterval time.Duration fleetInterval time.Duration + fleetAgentVersion string + fleetHarnessID string + fleetHarnessVersion string + fleetConnectorVersion string fleetControlPath string lifecycleGuardPath string fleetControl authority.FleetNodeControl @@ -578,6 +589,10 @@ func Load(path string) (*Runtime, error) { if runtime.fleetInterval == 0 { runtime.fleetInterval = 30 * time.Second } + runtime.fleetAgentVersion = config.Fleet.AgentVersion + runtime.fleetHarnessID = config.Fleet.HarnessID + runtime.fleetHarnessVersion = config.Fleet.HarnessVersion + runtime.fleetConnectorVersion = config.Fleet.ConnectorVersion runtime.fleetControlPath = filepath.Join(directory, ".enterprise-fleet-control.json") runtime.lifecycleGuardPath = filepath.Join(directory, ".enterprise-lifecycle-applied.json") if config.Fleet.StateSyncEnabled { @@ -935,14 +950,66 @@ func (runtime *Runtime) ReportFleetStatus(ctx context.Context, status FleetNodeS client := runtime.rolloutClient tenantID, agentID, keyID := runtime.tenantID, runtime.rolloutAgentID, runtime.rolloutKeyID privateKey := append(ed25519.PrivateKey(nil), runtime.rolloutPrivate...) + profile, registry, receiptCapable := runtime.actionProfile, runtime.actionRegistry, runtime.receipts != nil + reportedAgentVersion, reportedHarnessID := runtime.fleetAgentVersion, runtime.fleetHarnessID + reportedHarnessVersion, reportedConnectorVersion := runtime.fleetHarnessVersion, runtime.fleetConnectorVersion runtime.mu.Unlock() now := time.Now().UTC() + if reportedAgentVersion != "" { + status.AgentVersion = reportedAgentVersion + } + if reportedHarnessID != "" { + status.HarnessID = reportedHarnessID + } + if reportedHarnessVersion != "" { + status.HarnessVersion = reportedHarnessVersion + } + if reportedConnectorVersion != "" { + status.ConnectorVersion = reportedConnectorVersion + } + harnessID := strings.TrimSpace(status.HarnessID) + if harnessID == "" { + harnessID = "pilot-native" + } + harnessVersion := strings.TrimSpace(status.HarnessVersion) + if harnessVersion == "" { + harnessVersion = status.AgentVersion + if harnessVersion == "" { + harnessVersion = "unknown" + } + } + connectorVersion := strings.TrimSpace(status.ConnectorVersion) + if connectorVersion == "" { + connectorVersion = "unknown" + } + capabilities := make([]actionregistry.AdapterCapability, 0) + if registry != nil && profile.Mode.Normalize() != actionregistry.ModeOff { + for _, definition := range registry.Definitions() { + if !profile.AppliesTo(registry, definition.Name) { + continue + } + enforce := profile.Mode.Enforces() + managed := profile.Mode.Managed() + capabilities = append(capabilities, actionregistry.AdapterCapability{ + Action: definition.Name, AdapterID: "pilot-action-hook", AdapterVersion: connectorVersion, + Observe: true, Enforce: enforce, Suspend: enforce && managed && definition.Suspendable, + Resume: enforce && managed && definition.Resumable, Receipt: receiptCapable, + }) + } + } + version := authority.FleetReportVersion + if len(capabilities) > 0 { + version = authority.FleetReportVersionV2 + } report := authority.FleetNodeReport{ - Version: authority.FleetReportVersion, TenantID: tenantID, AgentID: agentID, NodeID: status.NodeID, + Version: version, TenantID: tenantID, AgentID: agentID, NodeID: status.NodeID, AgentVersion: status.AgentVersion, ObservedAt: now.Unix(), UptimeSeconds: status.UptimeSeconds, Connections: status.Connections, Peers: status.Peers, EncryptedPeers: status.EncryptedPeers, BytesSent: status.BytesSent, BytesReceived: status.BytesReceived, PolicyRevision: status.PolicyRevision, KeyID: keyID, } + if version == authority.FleetReportVersionV2 { + report.HarnessID, report.HarnessVersion, report.ConnectorVersion, report.Capabilities = harnessID, harnessVersion, connectorVersion, capabilities + } if err := report.Sign(privateKey); err != nil { return err } @@ -1867,6 +1934,9 @@ func validateConfig(config Config) error { if filepath.IsAbs(config.Fleet.StateDirectory) { return fmt.Errorf("enterprise control: fleet.state_directory must be relative") } + if len(config.Fleet.AgentVersion) > 128 || strings.ContainsAny(config.Fleet.AgentVersion, "\r\n\x00") || config.Fleet.HarnessID != "" && !identifier(config.Fleet.HarnessID) || len(config.Fleet.HarnessVersion) > 128 || strings.ContainsAny(config.Fleet.HarnessVersion, "\r\n\x00") || config.Fleet.ConnectorVersion != "" && !identifier(config.Fleet.ConnectorVersion) { + return fmt.Errorf("enterprise control: fleet runtime and harness markers are invalid") + } } if config.OutboundDecisions != nil { outbound := config.OutboundDecisions diff --git a/internal/enterprisecontrol/control_test.go b/internal/enterprisecontrol/control_test.go index 0ed29df4..218dd439 100644 --- a/internal/enterprisecontrol/control_test.go +++ b/internal/enterprisecontrol/control_test.go @@ -197,7 +197,8 @@ func TestManagedActionApprovalSuspendsAndResumesExactlyOnce(t *testing.T) { } document.Rules = append([]decisionpolicy.Rule{{ ID: "allow-trust-accept", Agents: []string{"sender-a"}, Actions: []string{"trust.accept"}, - ResourcePrefixes: []string{"agent:"}, Risks: allRisks(), Outcome: decision.Allow, + ResourcePrefixes: []string{"agent:"}, Risks: allRisks(), Outcome: decision.ApprovalRequired, + Approval: &decisionpolicy.ApprovalPlan{ApproverKeyIDs: []string{"approval-key"}, RequiredApprovals: 1, ValiditySeconds: 3600, Outcome: decision.Allow}, }}, document.Rules...) payload, err := json.Marshal(document) if err != nil { diff --git a/pkg/daemon/zz_ipc_maxclients_test.go b/pkg/daemon/zz_ipc_maxclients_test.go index e55bf3d9..11e2ef5a 100644 --- a/pkg/daemon/zz_ipc_maxclients_test.go +++ b/pkg/daemon/zz_ipc_maxclients_test.go @@ -5,7 +5,6 @@ package daemon import ( "net" "os" - "path/filepath" "sync" "testing" "time" @@ -23,8 +22,9 @@ func TestIPCServer_MaxClientsCap(t *testing.T) { if testing.Short() { t.Skip("slow: opens many sockets") } - tmp := t.TempDir() - sockPath := filepath.Join(tmp, "pilot.sock") + // Keep the path below Darwin's short sockaddr_un.sun_path limit. + // t.TempDir includes the full test name and can exceed that limit. + sockPath := shortSockPath(t) d := New(Config{SocketPath: sockPath}) s := NewIPCServer(sockPath, d) diff --git a/pkg/daemon/zz_ipc_write_deadline_test.go b/pkg/daemon/zz_ipc_write_deadline_test.go index d9c594d8..fd9b6a5c 100644 --- a/pkg/daemon/zz_ipc_write_deadline_test.go +++ b/pkg/daemon/zz_ipc_write_deadline_test.go @@ -4,7 +4,6 @@ package daemon import ( "net" - "path/filepath" "testing" "time" @@ -26,8 +25,9 @@ func TestWriteLoopExitsOnWriteDeadline(t *testing.T) { } t.Parallel() - tmp := t.TempDir() - sockPath := filepath.Join(tmp, "deadline.sock") + // Keep the path below Darwin's short sockaddr_un.sun_path limit. + // t.TempDir includes the full test name and can exceed that limit. + sockPath := shortSockPath(t) ln, err := net.Listen("unix", sockPath) if err != nil { diff --git a/tests/zz_ipv6_test.go b/tests/zz_ipv6_test.go index 9bd7ad77..6849059a 100644 --- a/tests/zz_ipv6_test.go +++ b/tests/zz_ipv6_test.go @@ -55,8 +55,16 @@ func TestIPv6EndToEnd(t *testing.T) { registryAddr := r.Addr().String() t.Logf("registry on %s (IPv6)", registryAddr) + // Keep IPC paths below Darwin's short sockaddr_un.sun_path limit. The + // directory produced by t.TempDir includes this long test name, and daemon + // startup creates an additional staging component beside it. + tmpDir, err := os.MkdirTemp("", "w4-ipv6-") + if err != nil { + t.Fatalf("create short IPC directory: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) + // Helper to start a daemon with tunnel on [::1] - tmpDir := t.TempDir() startDaemon := func(idx int) (*daemon.Daemon, *driver.Driver) { t.Helper() sockPath := fmt.Sprintf("%s/d%d.sock", tmpDir, idx) From d48f0aace24994d0135ab101246eca2405e724f1 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Thu, 6 Aug 2026 19:02:01 +0300 Subject: [PATCH 5/6] fix(enterprise): install policy from enrollment claim --- cmd/pilotctl/enterprise_adopt.go | 42 ++++++++++----------------- cmd/pilotctl/enterprise_adopt_test.go | 12 ++++---- 2 files changed, 21 insertions(+), 33 deletions(-) diff --git a/cmd/pilotctl/enterprise_adopt.go b/cmd/pilotctl/enterprise_adopt.go index c9f11245..9e6834eb 100644 --- a/cmd/pilotctl/enterprise_adopt.go +++ b/cmd/pilotctl/enterprise_adopt.go @@ -111,17 +111,17 @@ func adoptEnterpriseNode(ctx context.Context, options enterpriseAdoptOptions) (e if err != nil { return enterpriseAdoptResult{}, err } - policy, err := fetchEnrollmentPolicy(ctx, client, endpoint, claim.TenantID, claim.AgentID) + policy, err := decodeEnrollmentPolicy(claim.Policy) if err != nil { return enterpriseAdoptResult{}, err } - if policy.Bundle.TenantID != claim.TenantID || policy.Bundle.Revision < credential.Trust.PolicyRevision { - return enterpriseAdoptResult{}, fmt.Errorf("active policy does not satisfy the enrolled trust floor") + if policy.TenantID != claim.TenantID || policy.Revision < credential.Trust.PolicyRevision || policy.RevocationEpoch < credential.Trust.RevocationEpoch { + return enterpriseAdoptResult{}, fmt.Errorf("bootstrap policy does not satisfy the enrolled trust floor") } - if err := policy.Bundle.Validate(); err != nil { - return enterpriseAdoptResult{}, fmt.Errorf("validate active policy: %w", err) + if err := policy.Validate(); err != nil { + return enterpriseAdoptResult{}, fmt.Errorf("validate bootstrap policy: %w", err) } - controlPath, err := installEnrolledAttachment(options.OutputDirectory, claim, credential, root, seed, policy.Bundle) + controlPath, err := installEnrolledAttachment(options.OutputDirectory, claim, credential, root, seed, policy) if err != nil { return enterpriseAdoptResult{}, err } @@ -162,7 +162,7 @@ func claimNodeEnrollment(ctx context.Context, client *http.Client, endpoint, tok if err := decoder.Decode(&claim); err != nil { return authorityhttp.NodeEnrollmentClaimResponse{}, fmt.Errorf("decode node enrollment: %w", err) } - if claim.Version != authorityhttp.NodeEnrollmentVersion || claim.EnrollmentID == "" || claim.TenantID == "" || claim.AgentID == "" || claim.HarnessID == "" || claim.RunID == "" || claim.ClaimedAt <= 0 || len(claim.Credential) == 0 || claim.PublicOrigin != endpoint || !claim.Options.FleetControl { + if claim.Version != authorityhttp.NodeEnrollmentVersion || claim.EnrollmentID == "" || claim.TenantID == "" || claim.AgentID == "" || claim.HarnessID == "" || claim.RunID == "" || claim.ClaimedAt <= 0 || len(claim.Credential) == 0 || len(claim.Policy) == 0 || claim.PublicOrigin != endpoint || !claim.Options.FleetControl { return authorityhttp.NodeEnrollmentClaimResponse{}, fmt.Errorf("node enrollment response is incomplete or mismatched") } if _, err := normalizedManagedEndpoint(claim.FederationEndpoint); err != nil { @@ -208,27 +208,17 @@ func validateEnrolledCredential(claim authorityhttp.NodeEnrollmentClaimResponse) return credential, root, seed, nil } -func fetchEnrollmentPolicy(ctx context.Context, client *http.Client, endpoint, tenantID, agentID string) (authorityhttp.ActivePolicyEnvelope, error) { - query := url.Values{"tenant_id": []string{tenantID}, "agent_id": []string{agentID}} - request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"/v1/policy-current?"+query.Encode(), nil) - if err != nil { - return authorityhttp.ActivePolicyEnvelope{}, err - } - response, err := client.Do(request) - if err != nil { - return authorityhttp.ActivePolicyEnvelope{}, fmt.Errorf("fetch active policy: %w", err) - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return authorityhttp.ActivePolicyEnvelope{}, fmt.Errorf("active policy returned HTTP %d", response.StatusCode) - } - decoder := json.NewDecoder(io.LimitReader(response.Body, 2<<20)) +func decodeEnrollmentPolicy(raw json.RawMessage) (authority.PolicyBundle, error) { + decoder := json.NewDecoder(io.LimitReader(bytes.NewReader(raw), 2<<20)) decoder.DisallowUnknownFields() - var active authorityhttp.ActivePolicyEnvelope - if err := decoder.Decode(&active); err != nil { - return authorityhttp.ActivePolicyEnvelope{}, fmt.Errorf("decode active policy: %w", err) + var policy authority.PolicyBundle + if err := decoder.Decode(&policy); err != nil { + return authority.PolicyBundle{}, fmt.Errorf("decode bootstrap policy: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return authority.PolicyBundle{}, fmt.Errorf("decode bootstrap policy: trailing data") } - return active, nil + return policy, nil } func installEnrolledAttachment(outputDirectory string, claim authorityhttp.NodeEnrollmentClaimResponse, credential enrolledNodeCredential, root ed25519.PublicKey, seed []byte, policy authority.PolicyBundle) (string, error) { diff --git a/cmd/pilotctl/enterprise_adopt_test.go b/cmd/pilotctl/enterprise_adopt_test.go index 92835f7b..4009e566 100644 --- a/cmd/pilotctl/enterprise_adopt_test.go +++ b/cmd/pilotctl/enterprise_adopt_test.go @@ -64,6 +64,10 @@ func TestAdoptEnterpriseNodeClaimsAndInstallsVerifiedAttachment(t *testing.T) { if err != nil { t.Fatal(err) } + policyRaw, err := json.Marshal(policy) + if err != nil { + t.Fatal(err) + } const token = "one-time-test-token" var server *httptest.Server @@ -78,14 +82,8 @@ func TestAdoptEnterpriseNodeClaimsAndInstallsVerifiedAttachment(t *testing.T) { _ = json.NewEncoder(writer).Encode(authorityhttp.NodeEnrollmentClaimResponse{ Version: authorityhttp.NodeEnrollmentVersion, EnrollmentID: "enrollment-123", TenantID: "tenant-a", AgentID: "agent-003", HarnessID: "gemini", DisplayName: "Finance agent", RunID: "run-123456", PublicOrigin: server.URL, FederationEndpoint: server.URL, - Options: authorityhttp.NodeEnrollmentOptions{ActionControl: true, FleetControl: true, StateSync: true}, Credential: credential, ClaimedAt: now.Unix(), + Options: authorityhttp.NodeEnrollmentOptions{ActionControl: true, FleetControl: true, StateSync: true}, Credential: credential, Policy: policyRaw, ClaimedAt: now.Unix(), }) - case "/v1/policy-current": - if request.URL.Query().Get("tenant_id") != "tenant-a" || request.URL.Query().Get("agent_id") != "agent-003" { - http.Error(writer, "bad scope", http.StatusBadRequest) - return - } - _ = json.NewEncoder(writer).Encode(authorityhttp.ActivePolicyEnvelope{Bundle: policy}) default: http.NotFound(writer, request) } From 56ee1b7904a742069210d89b6b41b7139afbd290 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Thu, 6 Aug 2026 19:32:56 +0300 Subject: [PATCH 6/6] fix(enterprise): acknowledge active policy enforcement --- internal/enterprisecontrol/control.go | 74 ++++++++++++++++------ internal/enterprisecontrol/control_test.go | 16 ++++- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/internal/enterprisecontrol/control.go b/internal/enterprisecontrol/control.go index 6a5159f0..be31d92b 100644 --- a/internal/enterprisecontrol/control.go +++ b/internal/enterprisecontrol/control.go @@ -399,15 +399,18 @@ func (recorder transportReceiptRecorder) newResultReceipt(intent decision.Intent // attachment. The root pin is configured out of band; this state prevents a // later daemon restart from accepting an older, still-valid signed bundle. type controlState struct { - TenantID string `json:"tenant_id"` - TrustRevision uint64 `json:"trust_revision"` - TrustPolicyRevision uint64 `json:"trust_policy_revision"` - TrustRevocationEpoch uint64 `json:"trust_revocation_epoch"` - PolicyRevision uint64 `json:"policy_revision"` - PolicyRevocationEpoch uint64 `json:"policy_revocation_epoch"` - MandateRevision uint64 `json:"mandate_revision,omitempty"` - MandateRevocationEpoch uint64 `json:"mandate_revocation_epoch,omitempty"` - MandateHash string `json:"mandate_hash,omitempty"` + TenantID string `json:"tenant_id"` + TrustRevision uint64 `json:"trust_revision"` + TrustPolicyRevision uint64 `json:"trust_policy_revision"` + TrustRevocationEpoch uint64 `json:"trust_revocation_epoch"` + PolicyRevision uint64 `json:"policy_revision"` + PolicyRevocationEpoch uint64 `json:"policy_revocation_epoch"` + MandateRevision uint64 `json:"mandate_revision,omitempty"` + MandateRevocationEpoch uint64 `json:"mandate_revocation_epoch,omitempty"` + MandateHash string `json:"mandate_hash,omitempty"` + EnforcementPublication string `json:"enforcement_publication,omitempty"` + EnforcementObservedAt int64 `json:"enforcement_observed_at,omitempty"` + EnforcementAckDelivered bool `json:"enforcement_ack_delivered,omitempty"` } // Load parses the strict JSON attachment at path, resolves its relative @@ -771,6 +774,11 @@ func (runtime *Runtime) reloadLocked() error { nextState.TrustRevision = trustBundle.Revision nextState.TrustPolicyRevision = trustBundle.PolicyRevision nextState.TrustRevocationEpoch = trustBundle.RevocationEpoch + if nextState.PolicyRevision != policyBundle.Revision { + nextState.EnforcementPublication = "" + nextState.EnforcementObservedAt = 0 + nextState.EnforcementAckDelivered = false + } nextState.PolicyRevision = policyBundle.Revision nextState.PolicyRevocationEpoch = policyBundle.RevocationEpoch if err := saveControlState(runtime.statePath, nextState); err != nil { @@ -1523,11 +1531,11 @@ func (runtime *Runtime) installTrustLocked(bundle authority.TrustBundle) error { if err := state.acceptsTrust(runtime.tenantID, bundle); err != nil { return err } - nextState := controlState{ - TenantID: runtime.tenantID, TrustRevision: bundle.Revision, TrustPolicyRevision: bundle.PolicyRevision, - TrustRevocationEpoch: bundle.RevocationEpoch, PolicyRevision: state.PolicyRevision, PolicyRevocationEpoch: state.PolicyRevocationEpoch, - MandateRevision: state.MandateRevision, MandateRevocationEpoch: state.MandateRevocationEpoch, MandateHash: state.MandateHash, - } + nextState := state + nextState.TenantID = runtime.tenantID + nextState.TrustRevision = bundle.Revision + nextState.TrustPolicyRevision = bundle.PolicyRevision + nextState.TrustRevocationEpoch = bundle.RevocationEpoch if err := runtime.trust.InstallWithCommit(bundle, func() error { if err := writeSecureJSON(runtime.trustPath, bundle); err != nil { return fmt.Errorf("enterprise control: persist current trust: %w", err) @@ -1568,6 +1576,7 @@ func (runtime *Runtime) stageCandidateLocked(ctx context.Context, candidate auth func (runtime *Runtime) installActivePolicyLocked(ctx context.Context, active authorityhttp.ActivePolicyEnvelope) error { publication, policy, activation := active.Publication, active.Bundle, active.Activation + observedAt := time.Now() if policy.TenantID != runtime.tenantID || publication.TenantID != runtime.tenantID || !publicationTargetsAgent(publication, runtime.rolloutAgentID) { return fmt.Errorf("enterprise control: active policy tenant binding mismatch") } @@ -1581,7 +1590,7 @@ func (runtime *Runtime) installActivePolicyLocked(ctx context.Context, active au if err := activation.VerifyFor(publication, policy, issuer, time.Now()); err != nil { return fmt.Errorf("enterprise control: verify active policy activation: %w", err) } - if activation.ActivatesAt > time.Now().Unix() { + if activation.ActivatesAt > observedAt.Unix() { return fmt.Errorf("enterprise control: active policy activation time has not arrived") } trust, err := runtime.trust.Current(ctx, runtime.tenantID) @@ -1595,10 +1604,17 @@ func (runtime *Runtime) installActivePolicyLocked(ctx context.Context, active au if err := state.accepts(runtime.tenantID, trust, policy); err != nil { return err } - nextState := controlState{ - TenantID: runtime.tenantID, TrustRevision: trust.Revision, TrustPolicyRevision: trust.PolicyRevision, - TrustRevocationEpoch: trust.RevocationEpoch, PolicyRevision: policy.Revision, PolicyRevocationEpoch: policy.RevocationEpoch, - MandateRevision: state.MandateRevision, MandateRevocationEpoch: state.MandateRevocationEpoch, MandateHash: state.MandateHash, + nextState := state + nextState.TenantID = runtime.tenantID + nextState.TrustRevision = trust.Revision + nextState.TrustPolicyRevision = trust.PolicyRevision + nextState.TrustRevocationEpoch = trust.RevocationEpoch + nextState.PolicyRevision = policy.Revision + nextState.PolicyRevocationEpoch = policy.RevocationEpoch + if nextState.EnforcementPublication != publication.ID { + nextState.EnforcementPublication = publication.ID + nextState.EnforcementObservedAt = observedAt.Unix() + nextState.EnforcementAckDelivered = false } if err := runtime.policies.InstallWithCommit(ctx, policy, func() error { if err := writeSecureJSON(runtime.policyPath, policy); err != nil { @@ -1608,6 +1624,23 @@ func (runtime *Runtime) installActivePolicyLocked(ctx context.Context, active au }); err != nil { return fmt.Errorf("enterprise control: install active policy: %w", err) } + if nextState.EnforcementAckDelivered { + return nil + } + ack, err := authority.NewPolicyAcknowledgement(publication, runtime.rolloutAgentID, authority.PolicyAckEnforced, time.Unix(nextState.EnforcementObservedAt, 0), runtime.rolloutKeyID) + if err != nil { + return fmt.Errorf("enterprise control: create enforced acknowledgement: %w", err) + } + if err := ack.Sign(runtime.rolloutPrivate); err != nil { + return fmt.Errorf("enterprise control: sign enforced acknowledgement: %w", err) + } + if _, err := runtime.rolloutClient.Acknowledge(ctx, ack); err != nil { + return fmt.Errorf("enterprise control: submit enforced acknowledgement: %w", err) + } + nextState.EnforcementAckDelivered = true + if err := saveControlState(runtime.statePath, nextState); err != nil { + return fmt.Errorf("enterprise control: persist enforced acknowledgement: %w", err) + } return nil } @@ -2140,6 +2173,9 @@ func loadControlState(path string) (controlState, error) { if (state.MandateRevision == 0) != (state.MandateHash == "") || (state.MandateRevision == 0) != (state.MandateRevocationEpoch == 0) || (state.MandateHash != "" && !lowerHex(state.MandateHash, 64)) { return controlState{}, fmt.Errorf("enterprise control: persisted mandate state is invalid") } + if (state.EnforcementPublication == "") != (state.EnforcementObservedAt == 0) || state.EnforcementAckDelivered && state.EnforcementPublication == "" || state.EnforcementPublication != "" && !identifier(state.EnforcementPublication) { + return controlState{}, fmt.Errorf("enterprise control: persisted enforcement acknowledgement is invalid") + } return state, nil } diff --git a/internal/enterprisecontrol/control_test.go b/internal/enterprisecontrol/control_test.go index 218dd439..3eb308b6 100644 --- a/internal/enterprisecontrol/control_test.go +++ b/internal/enterprisecontrol/control_test.go @@ -1219,8 +1219,14 @@ func TestRolloutRefreshStagesCandidateThenInstallsOnlyAuthorityActivePolicy(t *t if err := runtime.RefreshRollout(context.Background()); err != nil { t.Fatalf("install active policy: %v", err) } - if len(acknowledgements) != 1 || acknowledgements[0] != authority.PolicyAckStaged { - t.Fatalf("activation changed staged acknowledgement evidence = %v", acknowledgements) + if len(acknowledgements) != 2 || acknowledgements[0] != authority.PolicyAckStaged || acknowledgements[1] != authority.PolicyAckEnforced { + t.Fatalf("activation acknowledgement evidence = %v", acknowledgements) + } + if err := runtime.RefreshRollout(context.Background()); err != nil { + t.Fatalf("idempotent active refresh: %v", err) + } + if len(acknowledgements) != 2 { + t.Fatalf("active refresh resubmitted delivered acknowledgement = %v", acknowledgements) } allowed := signedFrameForControl(t, fixture, &dataexchange.Frame{Type: dataexchange.TypeBinary, Payload: []byte("active")}, 2) if err := dataConfig.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, allowed); err != nil { @@ -1240,6 +1246,12 @@ func TestRolloutRefreshStagesCandidateThenInstallsOnlyAuthorityActivePolicy(t *t if err := restartedData.GovernedVerifier.VerifyGovernedFrame(context.Background(), coreapi.Addr{}, allowed); err != nil { t.Fatalf("persisted authority-active policy was not restored: %v", err) } + if err := restarted.RefreshRollout(context.Background()); err != nil { + t.Fatalf("restart active refresh: %v", err) + } + if len(acknowledgements) != 2 { + t.Fatalf("restart resubmitted delivered acknowledgement = %v", acknowledgements) + } } func TestFleetControlReportsBoundedStatusAndVerifiesCommands(t *testing.T) {