From f6dc03104568dbb903f345e19b70aa9658cc1b86 Mon Sep 17 00:00:00 2001 From: Teo Calin Date: Thu, 1 Oct 2026 13:27:37 +0300 Subject: [PATCH] Remove the hosted control plane client The hosted control plane has been retired. Remove everything in this repo that existed only to talk to it: - pilotctl enterprise (adopt, status, dashboard-url, trust, policy, mandate, receipt, workflow, hook) and the governed-send path behind send-message/send-file --enterprise-control / --governed-resource. send-file now refuses those flags instead of ignoring them. - The daemon's -enterprise-control flag, discovery of ~/.pilot/managed/enterprise-control.json, fleet reporting and commands (including remote restart/shutdown), rollout refresh, state sync, managed app reconcile and receipt export. - internal/enterprisecontrol, internal/managedsdk, cmd/control-agent. - install.sh --managed-url and the managed-runtime-v* release tag trigger. -security-profile enterprise keeps its other locked settings but no longer requires -enterprise-control. SIGHUP is still caught and ignored. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 7 +- CHANGELOG.md | 22 + cmd/control-agent/main.go | 273 -- cmd/daemon/main.go | 329 +-- cmd/daemon/managed_control_test.go | 33 - cmd/daemon/proxy.go | 2 +- cmd/daemon/security_profile.go | 8 - cmd/daemon/security_profile_test.go | 21 +- cmd/daemon/shutdown.go | 12 +- cmd/daemon/shutdown_test.go | 30 +- cmd/pilotctl/config_values.go | 3 +- cmd/pilotctl/enterprise.go | 622 ----- cmd/pilotctl/enterprise_adopt.go | 377 --- cmd/pilotctl/enterprise_adopt_test.go | 140 - cmd/pilotctl/enterprise_hook.go | 411 --- cmd/pilotctl/enterprise_test.go | 467 ---- cmd/pilotctl/governed_transport.go | 333 --- cmd/pilotctl/governed_transport_test.go | 199 -- cmd/pilotctl/main.go | 184 +- cmd/pilotctl/zz_daemon_transport_test.go | 2 +- cmd/pilotctl/zz_more_cmds_test.go | 1 - docs/cli-reference.md | 3 +- install.sh | 377 +-- internal/enterprisecontrol/action_hook.go | 651 ----- .../action_hook_external_test.go | 96 - internal/enterprisecontrol/control.go | 2383 ----------------- internal/enterprisecontrol/control_test.go | 1878 ------------- internal/enterprisecontrol/fleet_apps.go | 462 ---- .../enterprisecontrol/fleet_apps_gate_test.go | 133 - internal/enterprisecontrol/fleet_apps_test.go | 471 ---- internal/enterprisecontrol/fleet_state.go | 746 ------ .../enterprisecontrol/fleet_state_test.go | 131 - internal/enterprisecontrol/mandates.go | 78 - .../zz_lifecycle_guard_test.go | 40 - internal/managedsdk/README.md | 14 - .../managedsdk/actioncontinuation/store.go | 490 ---- .../managedsdk/actionregistry/registry.go | 376 --- internal/managedsdk/authority/bundle.go | 298 --- .../managedsdk/authority/client_support.go | 65 - internal/managedsdk/authority/errors.go | 21 - internal/managedsdk/authority/fleet.go | 423 --- internal/managedsdk/authority/fleet_apps.go | 264 -- .../managedsdk/authority/fleet_control.go | 478 ---- internal/managedsdk/authority/fleet_state.go | 482 ---- .../managedsdk/authority/mandate_bundle.go | 274 -- internal/managedsdk/authority/policy.go | 403 --- internal/managedsdk/authority/rollout.go | 1389 ---------- internal/managedsdk/authority/store.go | 342 --- .../authorityhttp/agent_request_sign.go | 155 -- .../authorityhttp/agent_request_sign_test.go | 91 - internal/managedsdk/authorityhttp/client.go | 623 ----- .../managedsdk/authorityhttp/fleet_client.go | 232 -- internal/managedsdk/authorityhttp/types.go | 70 - internal/managedsdk/decisionhttp/client.go | 291 -- .../decisionhttp/evaluator_client.go | 74 - .../decisionhttp/federation_client.go | 176 -- .../decisionhttp/workflow_client.go | 276 -- .../decisionhttp/workflow_client_test.go | 43 - .../managedsdk/decisionhttp/workflow_types.go | 163 -- internal/managedsdk/decisionpolicy/policy.go | 669 ----- .../managedsdk/decisionpolicy/policy_test.go | 271 -- .../decisionpolicy/retention_test.go | 35 - .../decisionpolicy/rollout_simulator.go | 54 - .../managedsdk/decisionpolicy/simulation.go | 259 -- .../decisionpolicy/simulation_test.go | 175 -- 65 files changed, 115 insertions(+), 19786 deletions(-) delete mode 100644 cmd/control-agent/main.go delete mode 100644 cmd/daemon/managed_control_test.go delete mode 100644 cmd/pilotctl/enterprise.go delete mode 100644 cmd/pilotctl/enterprise_adopt.go delete mode 100644 cmd/pilotctl/enterprise_adopt_test.go delete mode 100644 cmd/pilotctl/enterprise_hook.go delete mode 100644 cmd/pilotctl/enterprise_test.go delete mode 100644 cmd/pilotctl/governed_transport.go delete mode 100644 cmd/pilotctl/governed_transport_test.go delete mode 100644 internal/enterprisecontrol/action_hook.go delete mode 100644 internal/enterprisecontrol/action_hook_external_test.go delete mode 100644 internal/enterprisecontrol/control.go delete mode 100644 internal/enterprisecontrol/control_test.go delete mode 100644 internal/enterprisecontrol/fleet_apps.go delete mode 100644 internal/enterprisecontrol/fleet_apps_gate_test.go delete mode 100644 internal/enterprisecontrol/fleet_apps_test.go delete mode 100644 internal/enterprisecontrol/fleet_state.go delete mode 100644 internal/enterprisecontrol/fleet_state_test.go delete mode 100644 internal/enterprisecontrol/mandates.go delete mode 100644 internal/enterprisecontrol/zz_lifecycle_guard_test.go delete mode 100644 internal/managedsdk/README.md delete mode 100644 internal/managedsdk/actioncontinuation/store.go delete mode 100644 internal/managedsdk/actionregistry/registry.go delete mode 100644 internal/managedsdk/authority/bundle.go delete mode 100644 internal/managedsdk/authority/client_support.go delete mode 100644 internal/managedsdk/authority/errors.go delete mode 100644 internal/managedsdk/authority/fleet.go delete mode 100644 internal/managedsdk/authority/fleet_apps.go delete mode 100644 internal/managedsdk/authority/fleet_control.go delete mode 100644 internal/managedsdk/authority/fleet_state.go delete mode 100644 internal/managedsdk/authority/mandate_bundle.go delete mode 100644 internal/managedsdk/authority/policy.go delete mode 100644 internal/managedsdk/authority/rollout.go delete mode 100644 internal/managedsdk/authority/store.go delete mode 100644 internal/managedsdk/authorityhttp/agent_request_sign.go delete mode 100644 internal/managedsdk/authorityhttp/agent_request_sign_test.go delete mode 100644 internal/managedsdk/authorityhttp/client.go delete mode 100644 internal/managedsdk/authorityhttp/fleet_client.go delete mode 100644 internal/managedsdk/authorityhttp/types.go delete mode 100644 internal/managedsdk/decisionhttp/client.go delete mode 100644 internal/managedsdk/decisionhttp/evaluator_client.go delete mode 100644 internal/managedsdk/decisionhttp/federation_client.go delete mode 100644 internal/managedsdk/decisionhttp/workflow_client.go delete mode 100644 internal/managedsdk/decisionhttp/workflow_client_test.go delete mode 100644 internal/managedsdk/decisionhttp/workflow_types.go delete mode 100644 internal/managedsdk/decisionpolicy/policy.go delete mode 100644 internal/managedsdk/decisionpolicy/policy_test.go delete mode 100644 internal/managedsdk/decisionpolicy/retention_test.go delete mode 100644 internal/managedsdk/decisionpolicy/rollout_simulator.go delete mode 100644 internal/managedsdk/decisionpolicy/simulation.go delete mode 100644 internal/managedsdk/decisionpolicy/simulation_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0e899b1..cb7e0bad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Release on: push: - tags: ['v*', 'managed-runtime-v*'] + tags: ['v*'] permissions: contents: write @@ -193,10 +193,7 @@ jobs: generate_release_notes: true draft: false prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') }} - # Managed runtimes are authority-pinned artifacts, not the generic - # Pilot update channel. Publishing one must not replace vX.Y.Z as - # GitHub's /releases/latest target for ordinary installers. - make_latest: ${{ startsWith(github.ref_name, 'managed-runtime-') && 'false' || 'true' }} + make_latest: 'true' # --------------------------------------------------------------------------- # Downstream fan-out is NO LONGER driven from here. diff --git a/CHANGELOG.md b/CHANGELOG.md index b1bff3c4..f20b3eb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -205,6 +205,28 @@ Detailed per-release notes are on the systemd the installer runs as root without `PILOT_ALLOW_ROOT` (hosted sandboxes run the agent as root); regular hosts still refuse root. +### Removed +- **Hosted control plane client.** The hosted control plane has been retired, + and everything in this repository that existed only to talk to it is gone: + - `pilotctl enterprise` (`adopt`, `status`, `dashboard-url`, `trust`, + `policy`, `mandate`, `receipt`, `workflow`, `hook`). + - The `--enterprise-control` and `--governed-resource` flags of + `pilotctl send-message` and `pilotctl send-file`. Both commands now + refuse those flags instead of sending ungoverned. + - The daemon's `-enterprise-control` flag, the `enterprise_control` config + key, automatic discovery of `~/.pilot/managed/enterprise-control.json`, + signed fleet reporting, fleet commands (including remote restart and + shutdown), policy rollout refresh, `.pilot` state sync, managed app + reconcile and receipt export. A leftover attachment file or config key is + ignored. + - The `control-agent` binary. + - `install.sh --managed-url` / `PILOT_MANAGEMENT_URL` / `--no-start`, and + the `managed-runtime-v*` release tag stream. +- `-security-profile enterprise` no longer requires `-enterprise-control` + while data exchange or event stream is enabled; its other locked settings + are unchanged. +- SIGHUP no longer reloads control state. It is still caught and ignored. + ### Changed - **Dependencies: skillinject v0.2.4, dataexchange v0.2.3, updater v0.2.5** (plus common v0.5.14, and the sigstore-go v1.3.0 / go-openapi versions diff --git a/cmd/control-agent/main.go b/cmd/control-agent/main.go deleted file mode 100644 index 20af7733..00000000 --- a/cmd/control-agent/main.go +++ /dev/null @@ -1,273 +0,0 @@ -// 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/pilotprotocol/internal/enterprisecontrol" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" -) - -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 { - // #nosec G115 -- nodeID is rejected above unless it fits exactly in uint32. - 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) - } - // #nosec G204,G702 -- restart re-execs the current OS-resolved binary directly; no shell or authority-supplied command is involved. - 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") - // #nosec G304 -- path is a fixed filename beneath the owner-only evidence directory established at startup. - 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 510b5553..a1452089 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -11,7 +11,6 @@ import ( "log" "log/slog" "os" - "os/exec" "os/signal" "path/filepath" "strconv" @@ -23,9 +22,7 @@ import ( "github.com/pilot-protocol/common/driver" "github.com/pilot-protocol/common/logging" "github.com/pilot-protocol/common/netproxy" - "github.com/pilot-protocol/pilotprotocol/internal/enterprisecontrol" "github.com/pilot-protocol/pilotprotocol/internal/logcap" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" "github.com/pilot-protocol/pilotprotocol/internal/motd" "github.com/pilot-protocol/pilotprotocol/internal/proxyconf" "github.com/pilot-protocol/pilotprotocol/pkg/daemon" @@ -50,8 +47,6 @@ 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") @@ -101,7 +96,6 @@ 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,11 +175,6 @@ func main() { if fileConfig != nil { config.ApplyToFlags(fileConfig) } - if *enterpriseControlPath == "" { - if discovered, ok := discoverManagedEnterpriseControl(); ok { - *enterpriseControlPath = discovered - } - } logging.Setup(*logLevel, *logFormat) // launchd never rotates StandardOutPath/StandardErrorPath (daemon.log @@ -318,9 +307,6 @@ func main() { 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) @@ -370,21 +356,6 @@ 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 { @@ -494,20 +465,13 @@ func main() { dataExchangeConfig := dataexchange.ServiceConfig{ IncludeBase64: *dataExchangeB64, } - 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 { - 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 { + if err := rt.Register(eventstream.NewService()); err != nil { log.Fatalf("register eventstream: %v", err) } } @@ -520,9 +484,6 @@ 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) } @@ -641,300 +602,18 @@ func main() { fatalAfterPluginStart(rt.StopPlugins, "daemon start: %v", err) } - 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) - } - } - }() - } - - if enterpriseControls.HasAppReconcile() { - appInstaller := enterprisecontrol.PilotctlInstaller{BinaryPath: pilotctlBinaryPath()} - reconcileApps(fleetControlCtx, enterpriseControls, appInstaller) - go func() { - ticker := time.NewTicker(enterpriseControls.AppReconcileInterval()) - defer ticker.Stop() - for { - select { - case <-fleetControlCtx.Done(): - return - case <-ticker.C: - reconcileApps(fleetControlCtx, enterpriseControls, appInstaller) - } - } - }() - } - - 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. + // SIGHUP is caught and ignored, so a closed terminal does not stop a + // foreground daemon. sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) - cause := awaitShutdown(sig, remoteLifecycleRequests, supervisorExitRequests, func() { - 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") - } - }) + cause := awaitShutdown(sig, supervisorExitRequests) signal.Stop(sig) - rolloutRefreshCancel() - receiptExportCancel() - fleetControlCancel() // Daemon.Stop then StopPlugins (see teardown for why the order // matters). A daemon-requested exit leaves here via os.Exit with its // code once the teardown finishes. shutdown(cause, func() { d.Stop() }, rt.StopPlugins, os.Exit) _ = proxyRelay.Close() // the plugins and apps are stopped - if cause.restart { - 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") - // #nosec G204,G702 -- restart re-execs the current OS-resolved daemon directly; signed fleet commands cannot supply a path or arguments. - // The launch environment, not os.Environ(): that may point the proxy - // variables at this daemon's proxy relay, which is gone once it execs. - if err := syscall.Exec(executable, os.Args, launchEnvironment); err != nil { - slog.Error("remote daemon restart failed", "err", err) - } - } -} - -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. -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()), - // #nosec G115 -- daemon counters are non-negative in-memory collection sizes and cannot exceed the process address space. - Connections: uint32(health.Connections), - // #nosec G115 -- daemon counters are non-negative in-memory collection sizes and cannot exceed the process address space. - Peers: uint32(health.Peers), - // #nosec G115 -- daemon counters are non-negative in-memory collection sizes and cannot exceed the process address space. - 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: - if controls.LifecycleCommandAlreadyApplied(command) { - outcome, detail = "rejected", "already_applied" - } else { - lifecycle = "restart" - } - case authority.FleetCommandShutdownRuntime: - if controls.LifecycleCommandAlreadyApplied(command) { - outcome, detail = "rejected", "already_applied" - } else { - 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 != "" { - // 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: - slog.Warn("fleet lifecycle request already pending", "command_id", command.ID) - } - } - } -} - -// pilotctlBinaryPath resolves the pilotctl that ships beside this daemon. -// Preferring the sibling binary over $PATH keeps the verified install path -// pinned to the same release as the daemon rather than to whatever a user -// happens to have earlier in their environment. -func pilotctlBinaryPath() string { - if executable, err := os.Executable(); err == nil { - sibling := filepath.Join(filepath.Dir(executable), "pilotctl") - if info, statErr := os.Stat(sibling); statErr == nil && !info.IsDir() { - return sibling - } - } - if resolved, err := exec.LookPath("pilotctl"); err == nil { - return resolved - } - return "pilotctl" -} - -// reconcileApps converges installed apps toward the authority's desired set. -// A failure here must never disturb policy enforcement or the state mirror, so -// it is logged and retried on the next tick rather than propagated. -func reconcileApps(ctx context.Context, controls *enterprisecontrol.Runtime, installer enterprisecontrol.AppInstaller) { - result, err := controls.ReconcileApps(ctx, installer) - if err != nil { - slog.Warn("managed app reconcile failed", "err", err) - return - } - if result.Installed+result.Staged+result.Removed+result.Failed > 0 { - slog.Info("managed apps reconciled", - "desired", result.Desired, "installed", result.Installed, - "awaiting_grants", result.Staged, "removed", result.Removed, "failed", result.Failed) - } -} - -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 { diff --git a/cmd/daemon/managed_control_test.go b/cmd/daemon/managed_control_test.go deleted file mode 100644 index e0e8b439..00000000 --- a/cmd/daemon/managed_control_test.go +++ /dev/null @@ -1,33 +0,0 @@ -// 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/daemon/proxy.go b/cmd/daemon/proxy.go index 3b2171ea..220436d3 100644 --- a/cmd/daemon/proxy.go +++ b/cmd/daemon/proxy.go @@ -140,7 +140,7 @@ func describeProxy(spec, transport string, r *netproxy.Resolver) string { // installDefaultTransportProxy makes net/http's shared DefaultTransport // follow the proxy resolver. Every HTTP client the daemon wires in without // a transport of its own — catalogue pins, skillinject, trustedagents, -// webhook, enterprise-control clients — uses DefaultTransport (or a clone +// webhook — uses DefaultTransport (or a clone // of it), and not all of them accept an injected client. Each new // connection takes the resolver's current settings, so rotated // credentials (-proxy-cmd) reach these clients too. With the proxy relay diff --git a/cmd/daemon/security_profile.go b/cmd/daemon/security_profile.go index 8dfb68f8..19af2515 100644 --- a/cmd/daemon/security_profile.go +++ b/cmd/daemon/security_profile.go @@ -27,9 +27,6 @@ type daemonSecurityOptions struct { SkillinjectVerificationKeyFound bool MOTDFeedURL string WebhookURL string - EnterpriseControlPath string - DisableDataExchange bool - DisableEventStream bool } func applyDaemonSecurityProfile(name string, o *daemonSecurityOptions) error { @@ -70,11 +67,6 @@ func applyDaemonSecurityProfile(name string, o *daemonSecurityOptions) error { 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) diff --git a/cmd/daemon/security_profile_test.go b/cmd/daemon/security_profile_test.go index 6d5dacf6..4ff059d3 100644 --- a/cmd/daemon/security_profile_test.go +++ b/cmd/daemon/security_profile_test.go @@ -6,11 +6,10 @@ import "testing" func validEnterpriseDaemonOptions() daemonSecurityOptions { return daemonSecurityOptions{ - RegistryAddr: "registry.example:443", - RegistryFingerprint: "abcdef", - RegistryTrust: "pinned", - IdentityPath: "/var/lib/pilot/identity.json", - EnterpriseControlPath: "control.json", + RegistryAddr: "registry.example:443", + RegistryFingerprint: "abcdef", + RegistryTrust: "pinned", + IdentityPath: "/var/lib/pilot/identity.json", } } @@ -52,7 +51,6 @@ func TestEnterpriseDaemonProfileRejectsUnsafeConfiguration(t *testing.T) { "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 @@ -78,17 +76,6 @@ func TestEnterpriseDaemonProfileAllowsSystemTrustAndLoopbackWebhook(t *testing.T } } -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"} diff --git a/cmd/daemon/shutdown.go b/cmd/daemon/shutdown.go index 26625a58..b09b8f69 100644 --- a/cmd/daemon/shutdown.go +++ b/cmd/daemon/shutdown.go @@ -32,27 +32,21 @@ func forwardExitRequest(req daemon.ExitRequest) { // shutdownCause records what ended the shutdown loop. type shutdownCause struct { - // restart: a signed fleet lifecycle command asked for a re-exec. - restart bool // exit: the daemon asked to exit for supervisor respawn. nil for - // signals and fleet lifecycle requests. + // signals. exit *daemon.ExitRequest } // awaitShutdown blocks until something asks the daemon to stop: SIGINT/ -// SIGTERM, a fleet lifecycle request, or a daemon exit request. SIGHUP -// runs onReload and keeps waiting. -func awaitShutdown(sig <-chan os.Signal, lifecycle <-chan string, exits <-chan daemon.ExitRequest, onReload func()) shutdownCause { +// SIGTERM or a daemon exit request. SIGHUP is ignored. +func awaitShutdown(sig <-chan os.Signal, exits <-chan daemon.ExitRequest) shutdownCause { for { select { case received := <-sig: if received == syscall.SIGHUP { - onReload() continue } return shutdownCause{} - case l := <-lifecycle: - return shutdownCause{restart: l == "restart"} case req := <-exits: return shutdownCause{exit: &req} } diff --git a/cmd/daemon/shutdown_test.go b/cmd/daemon/shutdown_test.go index 190df136..2f479aeb 100644 --- a/cmd/daemon/shutdown_test.go +++ b/cmd/daemon/shutdown_test.go @@ -58,15 +58,10 @@ func TestExitRequestRunsGracefulShutdownThenExits(t *testing.T) { // (the handler runs on the watchdog goroutine). forwardExitRequest(daemon.ExitRequest{Code: 1, Reason: "dup"}) - cause := awaitShutdown(make(chan os.Signal), make(chan string), supervisorExitRequests, func() { - t.Error("reload called without SIGHUP") - }) + cause := awaitShutdown(make(chan os.Signal), supervisorExitRequests) if cause.exit == nil || cause.exit.Code != 86 { t.Fatalf("cause = %+v, want exit request with code 86", cause) } - if cause.restart { - t.Fatal("exit request must not re-exec") - } var rec stepRecorder exitCode := -1 @@ -84,17 +79,17 @@ func TestExitRequestRunsGracefulShutdownThenExits(t *testing.T) { } // TestSignalShutdownDoesNotExit: SIGTERM keeps the old behavior — tear -// down and return from main (status 0), no forced exit code. +// down and return from main (status 0), no forced exit code. A SIGHUP +// ahead of it is ignored rather than ending the loop. func TestSignalShutdownDoesNotExit(t *testing.T) { sig := make(chan os.Signal, 2) sig <- syscall.SIGHUP sig <- syscall.SIGTERM - reloads := 0 - cause := awaitShutdown(sig, make(chan string), make(chan daemon.ExitRequest), func() { reloads++ }) - if reloads != 1 { - t.Fatalf("SIGHUP reloads = %d, want 1", reloads) + cause := awaitShutdown(sig, make(chan daemon.ExitRequest)) + if len(sig) != 0 { + t.Fatalf("awaitShutdown returned on SIGHUP, %d signal(s) left unread", len(sig)) } - if cause.exit != nil || cause.restart { + if cause.exit != nil { t.Fatalf("SIGTERM cause = %+v, want plain shutdown", cause) } @@ -108,17 +103,6 @@ func TestSignalShutdownDoesNotExit(t *testing.T) { } } -// TestLifecycleRestartCause: the signed fleet restart still maps to a -// re-exec, not an exit. -func TestLifecycleRestartCause(t *testing.T) { - lifecycle := make(chan string, 1) - lifecycle <- "restart" - cause := awaitShutdown(make(chan os.Signal), lifecycle, make(chan daemon.ExitRequest), func() {}) - if !cause.restart || cause.exit != nil { - t.Fatalf("cause = %+v, want restart", cause) - } -} - // TestTeardownStopsPluginsWhenDaemonStopHangs: a wedged transport can // hang Daemon.Stop. The plugins must still be stopped once the budget // runs out, or the app-store's children are orphaned when the exit diff --git a/cmd/pilotctl/config_values.go b/cmd/pilotctl/config_values.go index b564a5a0..0e6c01bb 100644 --- a/cmd/pilotctl/config_values.go +++ b/cmd/pilotctl/config_values.go @@ -26,7 +26,6 @@ var configValueKinds = map[string]string{ "email": "String", "encrypt": "Bool", "endpoint": "String", - "enterprise_control": "String", "hostname": "String", "identity": "String", "idle_timeout": "Duration", @@ -122,7 +121,7 @@ func validateConfigValue(key, raw string) (interface{}, error) { strings.ContainsAny(host, " /\\?#@\t\r\n") { return nil, fmt.Errorf("%s must be a host:port address", key) } - case "socket", "identity", "enterprise_control", "sandbox_dir": + case "socket", "identity", "sandbox_dir": if strings.ContainsRune(raw, 0) { return nil, fmt.Errorf("%s contains a NUL byte", key) } diff --git a/cmd/pilotctl/enterprise.go b/cmd/pilotctl/enterprise.go deleted file mode 100644 index 62db4430..00000000 --- a/cmd/pilotctl/enterprise.go +++ /dev/null @@ -1,622 +0,0 @@ -// 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/decision" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authorityhttp" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/decisionhttp" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/decisionpolicy" -) - -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 -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] - -External agent hooks (JSON request on stdin; invoked by an optional harness adapter): - pilotctl --json enterprise hook pre [--control ] - pilotctl --json enterprise hook post [--control ] - -One-time managed adoption (a core Pilot operation; no MCP server is required): - PILOT_ENROLLMENT_TOKEN=... pilotctl --json enterprise adopt --endpoint https://management.example -` - -// 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 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": - 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:]) - case "hook": - cmdEnterpriseHook(args[1:]) - default: - fatalHint("invalid_argument", "available: adopt, status, dashboard-url, trust, policy, mandate, receipt, workflow, hook", "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) - } - // #nosec G304 -- path is explicit operator input and enterpriseCertificateFile rejects unsafe file types and permissions first. - 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_adopt.go b/cmd/pilotctl/enterprise_adopt.go deleted file mode 100644 index 86f1e258..00000000 --- a/cmd/pilotctl/enterprise_adopt.go +++ /dev/null @@ -1,377 +0,0 @@ -// 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/decision" - "github.com/pilot-protocol/pilotprotocol/internal/enterprisecontrol" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/actionregistry" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authorityhttp" -) - -const defaultEnrollmentTokenEnvironment = "PILOT_ENROLLMENT_TOKEN" -const managedCoreConnectorVersion = "pilot-core-managed-0.1.4" - -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 := decodeEnrollmentPolicy(claim.Policy) - if err != nil { - return enterpriseAdoptResult{}, err - } - 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.Validate(); err != nil { - return enterpriseAdoptResult{}, fmt.Errorf("validate bootstrap policy: %w", err) - } - controlPath, err := installEnrolledAttachment(options.OutputDirectory, claim, credential, root, seed, policy) - 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 || 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 { - 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 decodeEnrollmentPolicy(raw json.RawMessage) (authority.PolicyBundle, error) { - decoder := json.NewDecoder(io.LimitReader(bytes.NewReader(raw), 2<<20)) - decoder.DisallowUnknownFields() - 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 policy, 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 - } - // #nosec G302 -- this is a directory; owner-only 0700 is the intended secure mode. - 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: managedCoreConnectorVersion, - StateSyncEnabled: claim.Options.StateSync, StateSyncIntervalSeconds: 2, - } - if claim.Options.StateSync { - config.Fleet.StateDirectory = "state" - } - } - // App reconciliation converges the installed set toward the desired - // document the App Store writes into fleet state. It is gated on state - // sync because both the desired document and the observed report travel - // as fleet state; without it the node would accept the option and then - // silently never reconcile. - if claim.Options.Apps && claim.Options.FleetControl && claim.Options.StateSync { - config.Apps = &enterprisecontrol.AppsConfig{ - Enabled: true, - // Left to the runtime defaults: apps install under ~/.pilot/apps - // and stage under ~/.pilot/apps-pending, which the runtime already - // enforces as siblings so a staged app is never inside the root the - // supervisor scans and can never be started before its grants are - // accepted. - ReconcileIntervalSeconds: 30, - } - } - 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 deleted file mode 100644 index 6d34298e..00000000 --- a/cmd/pilotctl/enterprise_adopt_test.go +++ /dev/null @@ -1,140 +0,0 @@ -// 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/decision" - "github.com/pilot-protocol/pilotprotocol/internal/enterprisecontrol" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authorityhttp" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/decisionpolicy" -) - -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) - } - policyRaw, err := json.Marshal(policy) - 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, Policy: policyRaw, ClaimedAt: now.Unix(), - }) - 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) - } - controlBody, err := os.ReadFile(result.ControlPath) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(controlBody), "pilot-mcp") || !strings.Contains(string(controlBody), `"connector_version": "`+managedCoreConnectorVersion+`"`) { - t.Fatalf("core adoption has the wrong connector ownership: %s", controlBody) - } - 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 deleted file mode 100644 index 3ee3eb93..00000000 --- a/cmd/pilotctl/enterprise_hook.go +++ /dev/null @@ -1,411 +0,0 @@ -// 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") - } - // #nosec G304 -- path is derived from a validated attempt ID beneath the private managed-hook directory and checked above. - 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/enterprise_test.go b/cmd/pilotctl/enterprise_test.go deleted file mode 100644 index 53a2773f..00000000 --- a/cmd/pilotctl/enterprise_test.go +++ /dev/null @@ -1,467 +0,0 @@ -// 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/decision" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authorityhttp" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/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/governed_transport.go b/cmd/pilotctl/governed_transport.go deleted file mode 100644 index a1be41ae..00000000 --- a/cmd/pilotctl/governed_transport.go +++ /dev/null @@ -1,333 +0,0 @@ -// 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 deleted file mode 100644 index 0dbca764..00000000 --- a/cmd/pilotctl/governed_transport_test.go +++ /dev/null @@ -1,199 +0,0 @@ -// 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 623c557e..10494b74 100644 --- a/cmd/pilotctl/main.go +++ b/cmd/pilotctl/main.go @@ -4,7 +4,6 @@ package main import ( "bufio" - "context" cryptorand "crypto/rand" "encoding/binary" "encoding/json" @@ -25,7 +24,6 @@ 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" @@ -892,7 +890,6 @@ 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. @@ -910,8 +907,6 @@ Flags: --timeout total wall time, including connect, handshake and reply --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}' @@ -1082,7 +1077,6 @@ Flags: 15s; 30s with --transport compat or auto) --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 --transport tunnel transport. Precedence: this flag, $PILOT_TRANSPORT, config "transport", else auto when @@ -1633,7 +1627,7 @@ Communication commands: pilotctl connect [port] [--message ] [--timeout ] pilotctl send --data [--timeout ] pilotctl recv [--count ] [--timeout ] - pilotctl send-file [--enterprise-control --governed-resource ] + pilotctl send-file pilotctl send-message --data [--type text|json|binary] [--count ] [--reuse-conn] [--wait ] pilotctl dgram --data pilotctl subscribe [--count ] [--timeout ] @@ -1691,7 +1685,6 @@ 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) @@ -1812,10 +1805,6 @@ dispatch: cmdReview(cmdArgs) return - case "enterprise": - cmdEnterprise(cmdArgs) - return - // Bootstrap case "init": cmdInit(cmdArgs) @@ -2917,12 +2906,6 @@ func planDaemonLaunch(args []string) daemonLaunchPlan { } } 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) - } - } var daemonArgs []string // In compat mode the raw-TCP production registry/beacon defaults are @@ -2970,9 +2953,6 @@ func planDaemonLaunch(args []string) daemonLaunchPlan { if trustAutoApprove { daemonArgs = append(daemonArgs, "--trust-auto-approve") } - if enterpriseControl != "" { - daemonArgs = append(daemonArgs, "--enterprise-control", enterpriseControl) - } // Daemon flags that are forwarded only when given, so a plain // `daemon start` keeps the daemon's own defaults. --endpoint and the // motd pair were documented here for a long time but never forwarded. @@ -4525,6 +4505,13 @@ func cmdSendFile(args []string) { if len(pos) < 2 { fatalCode("invalid_argument", "usage: pilotctl send-file [--timeout ]") } + // send-file ignores flags it does not know, so a caller still asking + // for a governed send must be refused rather than sent ungoverned. + for _, name := range []string{"enterprise-control", "governed-resource"} { + if _, ok := flags[name]; ok { + fatalCode("invalid_argument", "send-file: unknown flag --%s", name) + } + } // Default 90s is comfortable for transfers up to a hundred MiB over // a relay path; users with bigger files or slower peers should bump @@ -4584,10 +4571,6 @@ 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 @@ -4596,11 +4579,9 @@ 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, governedOutbound); serr == nil { + if res, serr := streamSendFile(d, target, filePath, filename, size, timeout); 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", @@ -4643,27 +4624,7 @@ func cmdSendFile(args []string) { stop := startWaitProgress(fmt.Sprintf("sending %s to %s", filename, target)) start := time.Now() - 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") + if err := client.SendFile(filename, data); err != nil { stop() fatalCode("connection_failed", "send failed: %v", err) } @@ -4689,7 +4650,6 @@ 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). @@ -4698,7 +4658,6 @@ 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 @@ -4723,11 +4682,6 @@ 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 @@ -4735,15 +4689,9 @@ 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) } @@ -4752,7 +4700,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, governedOutbound *governedOutboundSender) (map[string]interface{}, error) { +func streamSendFile(d *driver.Driver, target protocol.Addr, filePath, filename string, size int64, timeout time.Duration) (map[string]interface{}, error) { client, err := dataexchange.Dial(d, target) if err != nil { return nil, err @@ -4767,53 +4715,14 @@ func streamSendFile(d *driver.Driver, target protocol.Addr, filePath, filename s stop := startWaitProgress(fmt.Sprintf("streaming %s to %s", filename, target)) start := time.Now() - 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) - } + 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 @@ -4832,11 +4741,6 @@ func streamSendFile(d *driver.Driver, target protocol.Addr, filePath, filename s "transport": "filestream", "verified": res.OK, } - if governedOutbound != nil { - result["governed"] = true - result["decision_id"] = governedDecision.ID - result["policy_revision"] = governedDecision.PolicyRevision - } return result, nil } @@ -4844,7 +4748,7 @@ func cmdSendMessage(args []string) { flags, pos := parseFlags(args) for name := range flags { switch name { - case "data", "type", "count", "reuse-conn", "wait", "timeout", "no-resend", "trace", "no-auto-handshake", "enterprise-control", "governed-resource": + case "data", "type", "count", "reuse-conn", "wait", "timeout", "no-resend", "trace", "no-auto-handshake": default: fatalCode("invalid_argument", "send-message: unknown flag --%s", name) } @@ -4864,7 +4768,7 @@ func cmdSendMessage(args []string) { defer timer.Stop() } if len(pos) != 1 { - fatalCode("invalid_argument", "usage: pilotctl send-message --data [--type text|json|binary] [--trace] [--count ] [--reuse-conn] [--wait ] [--timeout ] [--no-resend] [--enterprise-control --governed-resource ]") + fatalCode("invalid_argument", "usage: pilotctl send-message --data [--type text|json|binary] [--trace] [--count ] [--reuse-conn] [--wait ] [--timeout ] [--no-resend]") } sendCount := flagInt(flags, "count", 1) @@ -4946,13 +4850,6 @@ 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. @@ -4974,20 +4871,7 @@ func cmdSendMessage(args []string) { sendOne := func(cl *dataexchange.Client, seq int, reused bool) map[string]interface{} { var sentAtNs int64 var sendErr error - 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 { + if traceTime { sentAtNs, sendErr = cl.SendTrace(innerType, []byte(data)) } else { sendStart := time.Now() @@ -5002,7 +4886,6 @@ 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()} } @@ -5010,15 +4893,6 @@ 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{}{ @@ -5026,11 +4900,6 @@ 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) } @@ -5105,11 +4974,6 @@ func cmdSendMessage(args []string) { defer cl.Close() r := sendOne(cl, 0, false) ackAt := time.Now() - 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(), @@ -5140,11 +5004,10 @@ func cmdSendMessage(args []string) { // side of the new path is still settling (measured: the query // then succeeded on the next attempt). Send the request once // more on a new stream if nothing arrived by mid-window. - // Governed sends are never repeated (each needs its own - // authorization); --no-resend opts out for requests that are - // not safe to repeat. + // --no-resend opts out for requests that are not safe to + // repeat. _, sendFailed := r["error"] - if firstContact && !sendFailed && governedOutbound == nil && !flagBool(flags, "no-resend") { + if firstContact && !sendFailed && !flagBool(flags, "no-resend") { if after := resendDelay(waitDur); after > 0 { cfg.resendAfter = after cfg.resend = func() (time.Time, error) { @@ -5210,11 +5073,6 @@ func cmdSendMessage(args []string) { var results []map[string]interface{} for i := 0; i < sendCount; i++ { 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) @@ -5234,12 +5092,6 @@ func cmdSendMessage(args []string) { for i := 0; i < sendCount; i++ { cl := dialOnce() 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 { diff --git a/cmd/pilotctl/zz_daemon_transport_test.go b/cmd/pilotctl/zz_daemon_transport_test.go index c033da77..48afbc07 100644 --- a/cmd/pilotctl/zz_daemon_transport_test.go +++ b/cmd/pilotctl/zz_daemon_transport_test.go @@ -419,7 +419,7 @@ exit 0 var baseDaemonFlags = []string{ "registry", "beacon", "listen", "socket", "identity", "log-level", "log-format", "encrypt", "email", "hostname", "config", "public", "webhook", "networks", - "trust-auto-approve", "enterprise-control", "endpoint", "compat-beacon", + "trust-auto-approve", "endpoint", "compat-beacon", "registry-trust", "registry-fingerprint", "tls-trust", "motd-feed-url", "motd-interval", } diff --git a/cmd/pilotctl/zz_more_cmds_test.go b/cmd/pilotctl/zz_more_cmds_test.go index ddeca9c0..4e3c5acd 100644 --- a/cmd/pilotctl/zz_more_cmds_test.go +++ b/cmd/pilotctl/zz_more_cmds_test.go @@ -124,7 +124,6 @@ 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/docs/cli-reference.md b/docs/cli-reference.md index cd6e67f0..28adad23 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -42,7 +42,7 @@ Communication commands: pilotctl connect [port] [--message ] [--timeout ] pilotctl send --data [--timeout ] pilotctl recv [--count ] [--timeout ] - pilotctl send-file [--enterprise-control --governed-resource ] + pilotctl send-file pilotctl send-message --data [--type text|json|binary] [--count ] [--reuse-conn] [--wait ] pilotctl dgram --data pilotctl subscribe [--count ] [--timeout ] @@ -100,7 +100,6 @@ 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) diff --git a/install.sh b/install.sh index fea6d263..ee82b5dd 100755 --- a/install.sh +++ b/install.sh @@ -16,8 +16,6 @@ set -e # before auto stay on UDP unless given --transport compat, and # use no proxy: the installer prints what to do instead. # Proxy credentials that rotate: see PILOT_PROXY_CMD below) -# Managed node: export PILOT_ENROLLMENT_TOKEN # enter it without putting it in shell history -# sh install.sh --managed-url https://management.pilotprotocol.network # Uninstall: curl -fsSL https://pilotprotocol.network/install.sh | sh -s uninstall # # Flags: @@ -38,12 +36,6 @@ set -e # $HTTPS_PROXY/$ALL_PROXY when set (CONNECT by hostname) — # for UDP-blocked hosts and agent sandboxes whose only way # out is an HTTPS proxy. -# --managed-url -# Install the checksum-pinned core managed runtime, claim -# a one-time hosted identity, and start signed reporting. -# This does not install pilot-mcp or a harness adapter. -# --no-start With --managed-url, install and adopt without starting -# the daemon. The hosted onboarding flow omits this flag. # # Legacy env vars (still honored, lower precedence than flags): # PILOT_RELEASE_TAG=vX.Y.Z Same as --version. @@ -64,9 +56,6 @@ set -e # PILOT_ALLOW_ROOT=1 Install as root on a host with systemd/launchd, # or under sudo/doas (not needed where the agent # itself is root in a container/VM without systemd). -# PILOT_MANAGEMENT_URL=https://management.example -# Same as --managed-url. Requires the one-time -# PILOT_ENROLLMENT_TOKEN on first adoption. # # Proxies: every download is a curl HTTPS request, so HTTPS_PROXY / https_proxy / # ALL_PROXY / NO_PROXY are honored (curl asks the proxy to CONNECT by hostname — @@ -145,7 +134,6 @@ REGISTRY="${PILOT_REGISTRY:-$DEFAULT_REGISTRY}" BEACON="${PILOT_BEACON:-$DEFAULT_BEACON}" PILOT_DIR="$HOME/.pilot" BIN_DIR="$PILOT_DIR/bin" -MANAGED_CONTROL_PATH="$PILOT_DIR/managed/enterprise-control.json" # validate_safe LABEL VALUE EXTRA — abort if VALUE contains any character # outside [A-Za-z0-9] plus the punctuation in EXTRA. These values are @@ -188,8 +176,6 @@ PILOT_REQUESTED_VERSION="" PILOT_REQUESTED_CHANNEL="" PILOT_YES=0 PILOT_NO_WARN=0 -PILOT_MANAGED_NO_START=0 -PILOT_MANAGEMENT_URL="${PILOT_MANAGEMENT_URL:-}" PILOT_POSITIONAL="" PILOT_REQUESTED_TRANSPORT="" @@ -214,13 +200,6 @@ while [ $# -gt 0 ]; do PILOT_YES=1; shift ;; --no-warn) PILOT_NO_WARN=1; shift ;; - --managed-url|--management-url) - if [ $# -lt 2 ]; then echo "Error: $1 requires an HTTPS origin" >&2; exit 2; fi - PILOT_MANAGEMENT_URL="$2"; shift 2 ;; - --managed-url=*|--management-url=*) - PILOT_MANAGEMENT_URL="${1#*=}"; shift ;; - --no-start) - PILOT_MANAGED_NO_START=1; shift ;; -h|--help) # The usage header: from line 4 up to "WHAT THIS SCRIPT DOES". awk 'NR >= 4 { if (/^# WHAT THIS SCRIPT DOES/) exit; print }' "$0" 2>/dev/null \ @@ -239,70 +218,6 @@ while [ $# -gt 0 ]; do esac done -PILOT_MANAGED_MODE=0 -MANAGED_TOKEN="" -if [ -n "$PILOT_MANAGEMENT_URL" ]; then - PILOT_MANAGED_MODE=1 - # Accept a cosmetic trailing slash, but require an HTTPS origin with no - # credentials, path, query, or fragment. The value later becomes both a - # manifest URL and the enrollment authority endpoint. - PILOT_MANAGEMENT_URL="${PILOT_MANAGEMENT_URL%/}" - case "$PILOT_MANAGEMENT_URL" in - https://*) ;; - *) echo "Error: --managed-url must be an HTTPS origin." >&2; exit 2 ;; - esac - _managed_host="${PILOT_MANAGEMENT_URL#https://}" - case "$_managed_host" in - ""|*/*|*@*|*\?*|*\#*) - echo "Error: --managed-url must not contain credentials, a path, query, or fragment." >&2 - exit 2 ;; - esac - validate_safe "management host" "$_managed_host" ".:-" - - if [ -n "$PILOT_POSITIONAL" ]; then - echo "Error: --managed-url cannot be combined with an install/uninstall positional command." >&2 - exit 2 - fi - if [ -n "$PILOT_REQUESTED_VERSION" ] || [ -n "${PILOT_RELEASE_TAG:-}" ] \ - || [ -n "$PILOT_REQUESTED_CHANNEL" ] || [ "${PILOT_RC:-}" = "1" ]; then - echo "Error: managed adoption uses the runtime pinned by the management authority; do not combine it with --version or --channel." >&2 - exit 2 - fi - - MANAGED_TOKEN="${PILOT_ENROLLMENT_TOKEN:-}" - # Do not let the bearer secret reach curl, tar, service managers, or any - # other child. It is exported only to the single pilotctl claim process. - unset PILOT_ENROLLMENT_TOKEN - if [ -e "$MANAGED_CONTROL_PATH" ]; then - if [ -L "$MANAGED_CONTROL_PATH" ] || [ ! -f "$MANAGED_CONTROL_PATH" ]; then - echo "Error: the existing managed control attachment is not a regular file." >&2 - exit 1 - fi - if [ -n "$MANAGED_TOKEN" ]; then - echo "Error: this node is already managed; refusing to consume a new enrollment token." >&2 - echo " Re-run without PILOT_ENROLLMENT_TOKEN to repair or update the managed runtime." >&2 - exit 1 - fi - else - if [ -z "$MANAGED_TOKEN" ]; then - echo "Error: PILOT_ENROLLMENT_TOKEN is required for first managed adoption." >&2 - exit 1 - fi - _managed_token_bytes=$(printf '%s' "$MANAGED_TOKEN" | wc -c | tr -d ' ') - if [ "$_managed_token_bytes" -gt 4096 ] \ - || LC_ALL=C printf '%s' "$MANAGED_TOKEN" | grep -q '[[:cntrl:]]'; then - echo "Error: PILOT_ENROLLMENT_TOKEN is invalid." >&2 - exit 1 - fi - fi - # Managed credentials and state created by this process default owner-only. - umask 077 - MANIFEST_URL="${PILOT_MANAGEMENT_URL}/.well-known/pilot-managed-runtime.json" -elif [ "$PILOT_MANAGED_NO_START" = "1" ]; then - echo "Error: --no-start is only valid with --managed-url." >&2 - exit 2 -fi - # Validate channel value early so we fail fast. `edge` is a back-compat alias # for `beta`: the manifest publishes channels.stable and channels.beta only, so # a literal `edge` lookup would resolve empty and (previously) silently fall @@ -376,47 +291,6 @@ if [ "${1:-}" != "uninstall" ] && [ "$(id -u)" = "0" ] && [ -z "${PILOT_ALLOW_RO fi fi -# A managed identity is per node, but the CLI links, service label and daemon -# socket are machine-wide. A different HOME therefore does not make a second -# installation safe. Refuse before downloading, stopping services, replacing -# links or consuming the enrollment token. An existing attachment in this -# same PILOT_DIR remains the ordinary repair/update path handled above. -if [ "$PILOT_MANAGED_MODE" = "1" ] && [ ! -e "$MANAGED_CONTROL_PATH" ] \ - && [ "${PILOT_REPLACE_EXISTING_NODE:-}" != "1" ]; then - _pilot_collision="" - for _pilot_link in /usr/local/bin/pilotctl /usr/local/bin/pilot-daemon; do - if [ -L "$_pilot_link" ]; then - _pilot_target=$(readlink "$_pilot_link" 2>/dev/null || true) - case "$_pilot_target" in - "$BIN_DIR"/*) ;; - */.pilot/bin/*) _pilot_collision="${_pilot_collision}${_pilot_collision:+, }$_pilot_link -> $_pilot_target" ;; - esac - fi - done - _pilot_os=$(uname -s | tr '[:upper:]' '[:lower:]') - if [ "$_pilot_os" = "darwin" ] && command -v launchctl >/dev/null 2>&1; then - _pilot_service=$(launchctl print "gui/$(id -u)/network.pilotprotocol.pilot-daemon" 2>/dev/null || true) - if [ -n "$_pilot_service" ] && ! printf '%s\n' "$_pilot_service" | grep -Fq "$BIN_DIR/pilot-daemon"; then - _pilot_collision="${_pilot_collision}${_pilot_collision:+, }LaunchAgent network.pilotprotocol.pilot-daemon" - fi - elif [ "$_pilot_os" = "linux" ] && command -v systemctl >/dev/null 2>&1; then - _pilot_service=$(systemctl cat pilot-daemon 2>/dev/null || true) - if [ -n "$_pilot_service" ] && ! printf '%s\n' "$_pilot_service" | grep -Fq "$BIN_DIR/pilot-daemon"; then - _pilot_collision="${_pilot_collision}${_pilot_collision:+, }systemd pilot-daemon" - fi - fi - if [ -n "$_pilot_collision" ]; then - MANAGED_TOKEN="" - unset MANAGED_TOKEN - echo "Error: another Pilot node installation already owns machine-wide resources." >&2 - echo " Detected: $_pilot_collision" >&2 - echo " This enrollment token was not consumed and nothing was changed." >&2 - echo " Manage or remove the existing node first; do not run two node identities under one service label." >&2 - exit 1 - fi - _pilot_collision=""; _pilot_target=""; _pilot_service=""; _pilot_os="" -fi - # The transport already saved in config.json, if any ("udp", "compat", # "auto"). A re-run without --transport keeps it, so regenerated service # units stay consistent with it. @@ -604,9 +478,9 @@ manifest_platform_field() { # manifest_describes_tag "" "-" "" — whether the # manifest's per-platform entry is the archive of . The manifest carries # hashes for one release only: the platform url names it -# (…/releases/download//…), and a manifest without urls (the managed -# runtime's) describes its latest_stable. Any other tag — a --version pin, the -# beta channel — has no second anchor, so its hash must not be compared. +# (…/releases/download//…), and a manifest without urls describes its +# latest_stable. Any other tag — a --version pin, the beta channel — has no +# second anchor, so its hash must not be compared. manifest_describes_tag() { [ "$1" = "$(manifest_field "latest_stable" "$3")" ] && return 0 case "$(manifest_platform_field "$2" url "$3")" in @@ -889,11 +763,6 @@ HAVE_MANIFEST=0 if fetch_manifest "$MANIFEST_FILE"; then HAVE_MANIFEST=1 fi -if [ "$PILOT_MANAGED_MODE" = "1" ] && [ "$HAVE_MANIFEST" != "1" ]; then - echo "Error: the management authority did not publish a managed-runtime manifest." >&2 - echo " No binary or enrollment state was changed." >&2 - exit 1 -fi if [ -n "$PILOT_REQUESTED_VERSION" ]; then TAG="$PILOT_REQUESTED_VERSION" @@ -928,16 +797,6 @@ else | tr -d '\r' | head -1) fi -if [ "$PILOT_MANAGED_MODE" = "1" ]; then - case "$TAG" in - managed-runtime-v[0-9]*.[0-9]*.[0-9]*) ;; - *) - echo "Error: the management authority published an invalid managed-runtime version." >&2 - exit 1 ;; - esac - validate_safe "managed runtime version" "$TAG" ".-" -fi - # Fail loudly if the user explicitly asked for a released version/channel but it # resolved to nothing. Silently dropping to the unpinned, UNVERIFIED source # build below would give the user a binary they never asked for, with none of @@ -1071,11 +930,6 @@ if [ -n "$TAG" ]; then fi tar -xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR" --strip-components=1 else - if [ "$PILOT_MANAGED_MODE" = "1" ]; then - echo "Error: managed runtime ${TAG} could not be downloaded." >&2 - echo " Refusing to fall back to an unmanaged build." >&2 - exit 1 - fi # Archive download failed. Only the automatic default path may fall # back to a source build; an explicit request already hard-failed # above, so reaching here means no version/channel was pinned. @@ -1142,17 +996,6 @@ if [ -z "$STAGED_DAEMON" ] || [ -z "$STAGED_CTL" ]; then echo " Nothing was replaced — your existing install is untouched." >&2 exit 1 fi -if [ "$PILOT_MANAGED_MODE" = "1" ]; then - _managed_probe=$(PILOT_ENROLLMENT_TOKEN='' "$STAGED_CTL" --json enterprise adopt --endpoint "$PILOT_MANAGEMENT_URL" 2>&1 || true) - case "$_managed_probe" in - *PILOT_ENROLLMENT_TOKEN*) ;; - *) - echo "Error: ${TAG} does not contain core managed-adoption support." >&2 - echo " Nothing was replaced and the enrollment token was not consumed." >&2 - exit 1 ;; - esac - _managed_probe="" -fi # gateway is optional: extracted to a sibling repo, no longer ships in # release tarballs (release.yml BINS=daemon/pilotctl/updater) and the # source build only runs when ./cmd/gateway is present in the checkout. @@ -1207,10 +1050,7 @@ if [ "$OS" = "linux" ] && [ "$CAN_PRIV" = true ] \ fi ;; esac if [ -n "$_want" ]; then - if { [ "$PILOT_MANAGED_MODE" != "1" ] || [ "$PILOT_MANAGED_NO_START" != "1" ]; } \ - && { [ "$PILOT_MANAGED_MODE" != "1" ] || [ "$_svc" != "pilot-updater" ]; }; then - RESTART_SYSTEMD="${RESTART_SYSTEMD}${RESTART_SYSTEMD:+ }${_svc}" - fi + RESTART_SYSTEMD="${RESTART_SYSTEMD}${RESTART_SYSTEMD:+ }${_svc}" # shellcheck disable=SC2086 # $PILOT_SUDO is "" or "sudo" — intentional split $PILOT_SUDO systemctl stop "$_svc" 2>/dev/null || true echo " Stopped ${_svc} (will restart after upgrade)" @@ -1231,13 +1071,10 @@ if [ "$OS" = "darwin" ]; then echo "Error: could not unload ${_label}; aborting upgrade" >&2 exit 1 fi - if { [ "$PILOT_MANAGED_MODE" != "1" ] || [ "$PILOT_MANAGED_NO_START" != "1" ]; } \ - && { [ "$PILOT_MANAGED_MODE" != "1" ] || [ "$_new_label" != "network.pilotprotocol.pilot-updater" ]; }; then - case " $RESTART_LAUNCHD " in - *" ${_new_label} "*) ;; - *) RESTART_LAUNCHD="${RESTART_LAUNCHD}${RESTART_LAUNCHD:+ }${_new_label}" ;; - esac - fi + case " $RESTART_LAUNCHD " in + *" ${_new_label} "*) ;; + *) RESTART_LAUNCHD="${RESTART_LAUNCHD}${RESTART_LAUNCHD:+ }${_new_label}" ;; + esac echo " Unloaded ${_label} (will reload as ${_new_label} after upgrade)" fi if [ "$_new_label" != "$_label" ] && [ -f "$_lp" ]; then @@ -1271,32 +1108,6 @@ install_bin "$STAGED_CTL" "$BIN_DIR/pilotctl" [ -n "$STAGED_GATEWAY" ] && install_bin "$STAGED_GATEWAY" "$BIN_DIR/pilot-gateway" [ -n "$STAGED_UPDATER" ] && install_bin "$STAGED_UPDATER" "$BIN_DIR/pilot-updater" -# --- Optional hosted adoption (core Pilot, not MCP) --- -# -# The one-time token is exposed only to this process. pilotctl validates the -# delegated key, root pin, trust bundle, bootstrap policy, authority origins, -# and owner-only output before atomically installing ~/.pilot/managed. -MANAGED_ADOPTED=0 -if [ "$PILOT_MANAGED_MODE" = "1" ] && [ ! -e "$MANAGED_CONTROL_PATH" ]; then - if ! _managed_result=$(PILOT_ENROLLMENT_TOKEN="$MANAGED_TOKEN" "$BIN_DIR/pilotctl" --json enterprise adopt --endpoint "$PILOT_MANAGEMENT_URL" 2>&1); then - MANAGED_TOKEN="" - unset MANAGED_TOKEN - echo "Error: the hosted authority did not complete managed adoption." >&2 - printf '%s\n' "$_managed_result" >&2 - exit 1 - fi - MANAGED_TOKEN="" - unset MANAGED_TOKEN - _managed_result="" - if [ -L "$MANAGED_CONTROL_PATH" ] || [ ! -f "$MANAGED_CONTROL_PATH" ]; then - echo "Error: managed adoption returned without installing the verified control attachment." >&2 - exit 1 - fi - MANAGED_ADOPTED=1 -fi -MANAGED_TOKEN="" -unset MANAGED_TOKEN - # --- Symlink into /usr/local/bin so NON-INTERACTIVE shells can find pilotctl --- # # This symlink is the only thing that makes `pilotctl` resolve from a @@ -1337,7 +1148,7 @@ if [ "$LINK_OK" = true ]; then # shellcheck disable=SC2086 $LINK_SUDO ln -sf "$BIN_DIR/pilot-gateway" "$LINK_DIR/pilot-gateway" 2>/dev/null || true fi - if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ]; then + if [ -f "$BIN_DIR/pilot-updater" ]; then # shellcheck disable=SC2086 $LINK_SUDO ln -sf "$BIN_DIR/pilot-updater" "$LINK_DIR/pilot-updater" 2>/dev/null || true fi @@ -1733,13 +1544,7 @@ service_proxy_note() { # only when the file is absent so an operator who later runs `pilotctl update # disable` is never silently re-enabled. Turn off any time with # `pilotctl update disable`. -if [ "$PILOT_MANAGED_MODE" = "1" ]; then - # Stable-channel updater builds do not yet carry the managed control - # client. Pin this authority-selected runtime instead of allowing - # a background downgrade to silently remove enforcement. - printf '{\n "enabled": false,\n "reason": "managed-runtime-pinned-by-authority"\n}\n' > "$PILOT_DIR/auto-update.json" - echo "Auto-updates pinned to the management authority runtime channel" -elif [ ! -f "$PILOT_DIR/auto-update.json" ]; then +if [ ! -f "$PILOT_DIR/auto-update.json" ]; then printf '{\n "enabled": true\n}\n' > "$PILOT_DIR/auto-update.json" echo "Auto-updates ENABLED (opt-out) — disable with: pilotctl update disable" fi @@ -1750,8 +1555,6 @@ fi # what makes re-running the installer a real repair path: a host whose unit was # written by an older, buggy installer gets a correct one without uninstalling. -MANAGED_START_STYLE="" - if [ "$OS" = "linux" ] && command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then if [ "$CAN_PRIV" = true ]; then echo "Setting up systemd service..." @@ -1814,7 +1617,7 @@ RestartSec=5 WantedBy=multi-user.target SVC # Auto-updater service - if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ]; then + if [ -f "$BIN_DIR/pilot-updater" ]; then # shellcheck disable=SC2086 $PILOT_SUDO tee /etc/systemd/system/pilot-updater.service >/dev/null </dev/null || true - fi echo " Service: pilot-daemon.service" - if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ]; then + if [ -f "$BIN_DIR/pilot-updater" ]; then echo " Service: pilot-updater.service (auto-updates)" fi service_proxy_note "pilot-daemon.service" @@ -1863,7 +1660,7 @@ USVC # operator-tunable flags (-public, -hostname, registry overrides) that the # operator may want to set before first start; on a re-run anything that # was already running is restored below. - if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ]; then + if [ -f "$BIN_DIR/pilot-updater" ]; then # shellcheck disable=SC2086 if $PILOT_SUDO systemctl enable --now pilot-updater; then echo " Started: pilot-updater (check automatic-update state: pilotctl update status)" @@ -1884,51 +1681,38 @@ USVC fi done - if [ "$PILOT_MANAGED_MODE" = "1" ] && [ "$PILOT_MANAGED_NO_START" != "1" ]; then - # Managed onboarding promises a live signed check-in, so unlike an - # ordinary local install it explicitly enables the daemon now. - # shellcheck disable=SC2086 - $PILOT_SUDO systemctl enable --now pilot-daemon - MANAGED_START_STYLE="systemd" - echo " Started: pilot-daemon (managed reporting enabled)" - elif [ "$PILOT_MANAGED_MODE" != "1" ]; then - case " $RESTART_SYSTEMD " in - *" pilot-daemon "*) ;; - *) - if [ "$PROXY_UNSUPPORTED" = true ]; then - echo " Start daemon:" - start_hint " " "sudo systemctl enable --now pilot-daemon" - else - echo " Start daemon: sudo systemctl enable --now pilot-daemon" - fi ;; - esac - fi + case " $RESTART_SYSTEMD " in + *" pilot-daemon "*) ;; + *) + if [ "$PROXY_UNSUPPORTED" = true ]; then + echo " Start daemon:" + start_hint " " "sudo systemctl enable --now pilot-daemon" + else + echo " Start daemon: sudo systemctl enable --now pilot-daemon" + fi ;; + esac else echo " Skipped systemd setup (run as root or with passwordless sudo to enable)" - if [ "$PILOT_MANAGED_MODE" != "1" ]; then - if [ "$PROXY_UNSUPPORTED" = true ]; then - echo " Start the daemon without a service manager:" - start_hint " " "pilotctl daemon start" - else - echo " Start the daemon without a service manager: pilotctl daemon start" - fi + if [ "$PROXY_UNSUPPORTED" = true ]; then + echo " Start the daemon without a service manager:" + start_hint " " "pilotctl daemon start" + else + echo " Start the daemon without a service manager: pilotctl daemon start" fi fi elif [ "$OS" = "linux" ]; then # systemd is not the init system here (container / WSL / CI runner / # hosted agent sandbox). There is no service to install — tell the agent # the portable start path instead of silently leaving it with no daemon. - if [ "$PILOT_MANAGED_MODE" != "1" ]; then - if [ "$PROXY_ONLY" = true ]; then - echo "No systemd detected (container / WSL / CI / sandbox):" - else - echo "No systemd detected (container / WSL / CI / sandbox) — start the daemon manually:" - fi - start_hint " " "pilotctl daemon start" - if [ "$PROXY_UNSUPPORTED" != true ] && [ "$EFFECTIVE_TRANSPORT" != "udp" ]; then - echo " (transport=${EFFECTIVE_TRANSPORT}; start it from a shell that has HTTPS_PROXY" - echo " set if this host reaches the internet only through a proxy)" - fi + if [ "$PROXY_ONLY" = true ]; then + echo "No systemd detected (container / WSL / CI / sandbox):" + else + echo "No systemd detected (container / WSL / CI / sandbox) — start the daemon manually:" + fi + start_hint " " "pilotctl daemon start" + if [ "$PROXY_UNSUPPORTED" != true ] && [ "$EFFECTIVE_TRANSPORT" != "udp" ]; then + echo " (transport=${EFFECTIVE_TRANSPORT}; start it from a shell that has HTTPS_PROXY" + echo " set if this host reaches the internet only through a proxy)" fi fi @@ -2015,7 +1799,7 @@ ${PLIST_ENV} RunAtLoad PLIST # Auto-updater LaunchAgent - if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ]; then + if [ -f "$BIN_DIR/pilot-updater" ]; then UPLIST="$PLIST_DIR/network.pilotprotocol.pilot-updater.plist" cat > "$UPLIST" < @@ -2044,13 +1828,7 @@ UPLIST fi echo " Service: network.pilotprotocol.pilot-daemon" - if [ "$PILOT_MANAGED_MODE" = "1" ]; then - _managed_updater_plist="$PLIST_DIR/network.pilotprotocol.pilot-updater.plist" - if [ -f "$_managed_updater_plist" ]; then - launchctl unload -w "$_managed_updater_plist" 2>/dev/null || true - fi - fi - if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ]; then + if [ -f "$BIN_DIR/pilot-updater" ]; then echo " Service: network.pilotprotocol.pilot-updater (auto-updates)" fi service_proxy_note "the launchd agent" @@ -2065,7 +1843,7 @@ UPLIST # idempotent: any stale running agent is replaced cleanly. -w persists # the load across reboots. The daemon is left as opt-in for the same # reason as the Linux branch. - if [ "$PILOT_MANAGED_MODE" != "1" ] && [ -f "$BIN_DIR/pilot-updater" ] && [ -f "$UPLIST" ]; then + if [ -f "$BIN_DIR/pilot-updater" ] && [ -f "$UPLIST" ]; then launchctl unload "$UPLIST" 2>/dev/null || true launchctl load -w "$UPLIST" echo " Started: pilot-updater (check automatic-update state: pilotctl update status)" @@ -2087,47 +1865,18 @@ UPLIST fi done - if [ "$PILOT_MANAGED_MODE" != "1" ]; then - case " $RESTART_LAUNCHD " in - *" network.pilotprotocol.pilot-daemon "*) ;; - *) - if [ "$PROXY_UNSUPPORTED" = true ]; then - echo " Start daemon:" - start_hint " " "launchctl load -w $PLIST" "launchctl load -w $PLIST" - else - echo " Start daemon: launchctl load -w $PLIST" - fi - echo " Stop daemon: launchctl unload $PLIST" - ;; - esac - fi -fi - -# A managed Connect Agent run is complete only when the newly adopted core -# daemon is locally responsive. The management console independently waits for -# the signed remote report, so this check cannot forge onboarding success. -if [ "$PILOT_MANAGED_MODE" = "1" ]; then - if [ "$PILOT_MANAGED_NO_START" = "1" ]; then - echo "Managed runtime adopted but not started (--no-start)." - elif [ "$MANAGED_START_STYLE" = "systemd" ]; then - _managed_wait=0 - until "$BIN_DIR/pilotctl" daemon status --check >/dev/null 2>&1; do - _managed_wait=$((_managed_wait + 1)) - if [ "$_managed_wait" -ge 30 ]; then - echo "Error: the managed daemon did not become ready under systemd." >&2 - exit 1 + case " $RESTART_LAUNCHD " in + *" network.pilotprotocol.pilot-daemon "*) ;; + *) + if [ "$PROXY_UNSUPPORTED" = true ]; then + echo " Start daemon:" + start_hint " " "launchctl load -w $PLIST" "launchctl load -w $PLIST" + else + echo " Start daemon: launchctl load -w $PLIST" fi - sleep 1 - done - else - # Portable and launchd installs need an explicit restart so a daemon - # that was already running cannot keep the pre-adoption configuration. - if "$BIN_DIR/pilotctl" daemon status --check >/dev/null 2>&1; then - "$BIN_DIR/pilotctl" daemon stop >/dev/null - fi - "$BIN_DIR/pilotctl" daemon start --wait 30s >/dev/null - "$BIN_DIR/pilotctl" daemon status --check >/dev/null - fi + echo " Stop daemon: launchctl unload $PLIST" + ;; + esac fi # --- Add to PATH --- @@ -2199,30 +1948,6 @@ fi # Write version file for the auto-updater [ -n "$TAG" ] && echo "$TAG" > "$BIN_DIR/.pilot-version" -if [ "$PILOT_MANAGED_MODE" = "1" ]; then - echo "" - echo "Managed Pilot node ready:" - echo " Runtime: ${TAG}" - echo " Authority: ${PILOT_MANAGEMENT_URL}" - echo " Control: ${MANAGED_CONTROL_PATH}" - if [ "$MANAGED_ADOPTED" = "1" ]; then - echo " Enrollment: claimed once and installed" - else - echo " Enrollment: existing managed identity preserved" - fi - if [ "$PILOT_MANAGED_NO_START" = "1" ]; then - echo " Daemon: not started (--no-start)" - else - echo " Daemon: running; signed fleet reporting enabled" - fi - echo " MCP: not installed (optional, separate product)" - echo "" - echo "Harness interception is a separate optional attachment step." - echo "The management console will verify the node's signed report before" - echo "it marks onboarding complete." - exit 0 -fi - # transport_line — the transport the installed daemon will run, stated once # the binaries are known (the banner at the top does not guess), plus what to # do on a UDP-blocked host when this release will not fall back by itself. diff --git a/internal/enterprisecontrol/action_hook.go b/internal/enterprisecontrol/action_hook.go deleted file mode 100644 index 8fc3a7ed..00000000 --- a/internal/enterprisecontrol/action_hook.go +++ /dev/null @@ -1,651 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package enterprisecontrol - -import ( - "bytes" - "context" - "crypto/ed25519" - "errors" - "fmt" - "slices" - "time" - - "github.com/pilot-protocol/common/actionhook" - "github.com/pilot-protocol/common/decision" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/actioncontinuation" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/actionregistry" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/decisionhttp" -) - -type actionHookState struct { - selected bool - managed bool - federated bool - exchangeID string - intent decision.Intent - result decision.Decision - continuationID string - 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. -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 -} - -// 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 -// 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.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) - } - 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, - 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, - continuationID: claimed.ID, continuationLease: lease, - }, - }, 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 && - 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/action_hook_external_test.go b/internal/enterprisecontrol/action_hook_external_test.go deleted file mode 100644 index 3a9f58e0..00000000 --- a/internal/enterprisecontrol/action_hook_external_test.go +++ /dev/null @@ -1,96 +0,0 @@ -// 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 deleted file mode 100644 index 1f2cc252..00000000 --- a/internal/enterprisecontrol/control.go +++ /dev/null @@ -1,2383 +0,0 @@ -// 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/coreapi" - "github.com/pilot-protocol/common/decision" - "github.com/pilot-protocol/dataexchange" - "github.com/pilot-protocol/eventstream" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/actioncontinuation" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/actionregistry" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authorityhttp" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/decisionhttp" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/decisionpolicy" -) - -// 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"` - Apps *AppsConfig `json:"apps,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"` - 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"` -} - -// 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 - 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 -// 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 - fleetAgentVersion string - fleetHarnessID string - fleetHarnessVersion string - fleetConnectorVersion string - fleetControlPath string - lifecycleGuardPath string - fleetControl authority.FleetNodeControl - fleetControlFound bool - fleetStateEnabled bool - fleetStateInterval time.Duration - fleetStateRoot string - fleetStateCursorPath string - fleetStateRevision uint64 - fleetStateRootHash string - fleetStatePendingResults []authority.FleetStateMutationResult - appsEnabled bool - appsInterval time.Duration - appsInstallRoot string - appsStagingRoot string - appsMu sync.Mutex - appsManaged map[string]struct{} - 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"` - 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 -// 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") - } - requestSigner, signerErr := authorityhttp.NewAgentRequestSigner(config.OutboundDecisions.AgentID, config.OutboundDecisions.IntentKeyID, privateKey) - if signerErr != nil { - return nil, fmt.Errorf("enterprise control: configure workflow request signer: %w", signerErr) - } - if signerErr = client.ConfigureWorkflowAgentRequestSigning(config.TenantID, config.OutboundDecisions.AgentID, requestSigner.Sign); signerErr != nil { - return nil, fmt.Errorf("enterprise control: configure authenticated workflow reads: %w", signerErr) - } - 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.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 { - // 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 == "" { - 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) - } - 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.Apps != nil && config.Apps.Enabled { - // Apps ride on the state mirror: the desired document arrives as an - // ordinary signed state mutation, so without state sync there is no - // channel to receive one and nothing to reconcile toward. - if !runtime.fleetStateEnabled { - return nil, fmt.Errorf("enterprise control: app reconciliation requires fleet state sync") - } - home, homeErr := os.UserHomeDir() - if homeErr != nil && (config.Apps.InstallRoot == "" || config.Apps.StagingRoot == "") { - return nil, fmt.Errorf("enterprise control: app roots: %w", homeErr) - } - installRoot := strings.TrimSpace(config.Apps.InstallRoot) - if installRoot == "" { - installRoot = filepath.Join(home, ".pilot", "apps") - } - stagingRoot := strings.TrimSpace(config.Apps.StagingRoot) - if stagingRoot == "" { - // Deliberately a sibling of the install root, never inside it: the - // supervisor scans the install root, and an app awaiting grant - // acceptance must be somewhere it will not be spawned from. - stagingRoot = filepath.Join(home, ".pilot", "apps-pending") - } - if installRoot == stagingRoot { - return nil, fmt.Errorf("enterprise control: app staging_root must differ from install_root") - } - if withinDirectory(installRoot, stagingRoot) { - return nil, fmt.Errorf("enterprise control: app staging_root must not sit inside install_root") - } - runtime.appsEnabled = true - runtime.appsInstallRoot, runtime.appsStagingRoot = installRoot, stagingRoot - runtime.appsManaged = make(map[string]struct{}) - runtime.appsInterval = time.Duration(config.Apps.ReconcileIntervalSeconds) * time.Second - if runtime.appsInterval == 0 { - runtime.appsInterval = 30 * 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") - } - if signErr := client.ConfigureAgentRequestSigning(config.Rollout.AgentID, config.Rollout.AcknowledgementKeyID, privateKey); signErr != nil { - return nil, fmt.Errorf("enterprise control: configure authenticated node-plane reads: %w", signErr) - } - 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 - 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 { - 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...) - 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: 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 - } - 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 -} - -// 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 { - 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 := 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) - } - 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 - 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") - } - 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 > observedAt.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 := 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 { - 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) - } - 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 -} - -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 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 - 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") - } - // #nosec G304 -- callers provide fixed managed-state paths; the regular-file, no-symlink and permission checks above are mandatory. - 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") - } - 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 -} - -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) - } - // #nosec G304 -- directory is the parent of the already-confined managed state file and is opened only to fsync the atomic rename. - 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 deleted file mode 100644 index 3b6a1375..00000000 --- a/internal/enterprisecontrol/control_test.go +++ /dev/null @@ -1,1878 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package enterprisecontrol - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/pilot-protocol/common/actionhook" - "github.com/pilot-protocol/common/coreapi" - "github.com/pilot-protocol/common/decision" - "github.com/pilot-protocol/dataexchange" - "github.com/pilot-protocol/eventstream" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/actionregistry" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authorityhttp" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/decisionhttp" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/decisionpolicy" -) - -type controlFixture struct { - path string - rootPrivate ed25519.PrivateKey - intentPrivate ed25519.PrivateKey - decisionPrivate ed25519.PrivateKey - now time.Time -} - -type testApprovalAuthority struct { - mu sync.Mutex - decisionPrivate ed25519.PrivateKey - decisionPublic ed25519.PublicKey - approvalPublic ed25519.PublicKey - record decisionhttp.WorkflowRecord -} - -func (authority *testApprovalAuthority) authorize(writer http.ResponseWriter, request *http.Request) { - var intent decision.Intent - if request.Method != http.MethodPost || json.NewDecoder(request.Body).Decode(&intent) != nil || intent.Validate() != nil { - http.Error(writer, "invalid intent", http.StatusBadRequest) - return - } - intentHash, err := intent.Hash() - if err != nil { - http.Error(writer, "invalid intent", http.StatusBadRequest) - return - } - now := time.Now().UTC() - expiresAt := now.Add(time.Minute).Unix() - if intent.ExpiresAt < expiresAt { - expiresAt = intent.ExpiresAt - } - result := decision.Decision{ - Version: decision.SchemaVersion, ID: "approval-" + intentHash[:32], IntentHash: intentHash, - TenantID: intent.TenantID, AgentID: intent.AgentID, Outcome: decision.ApprovalRequired, - Reasons: []string{"approval-plan:trust-review:1"}, PolicyRevision: 1, RevocationEpoch: 1, - ProviderID: "test-authority", IssuedAt: now.Unix(), ExpiresAt: expiresAt, KeyID: "authority-key", - } - if err := result.Sign(authority.decisionPrivate); err != nil { - http.Error(writer, "sign decision", http.StatusInternalServerError) - return - } - writeApprovalTestJSON(writer, result) -} - -func (authority *testApprovalAuthority) begin(writer http.ResponseWriter, request *http.Request) { - var envelope decisionhttp.WorkflowBeginEnvelope - if request.Method != http.MethodPost || json.NewDecoder(request.Body).Decode(&envelope) != nil { - http.Error(writer, "invalid workflow", http.StatusBadRequest) - return - } - createdAt := time.Unix(envelope.Initial.IssuedAt, 0) - transaction, err := decision.NewApprovalTransaction( - envelope.Intent, envelope.Initial, decision.Allow, nil, []string{"approval-key"}, 1, - createdAt, createdAt.Add(time.Hour), "test-authority", "authority-key", - ) - if err == nil { - err = transaction.Sign(authority.decisionPrivate) - } - if err != nil { - http.Error(writer, "invalid workflow", http.StatusUnprocessableEntity) - return - } - authority.mu.Lock() - authority.record = decisionhttp.WorkflowRecord{Transaction: transaction} - record := authority.record - authority.mu.Unlock() - writeApprovalTestJSON(writer, record) -} - -func (authority *testApprovalAuthority) status(transactionID string) (decisionhttp.WorkflowRecord, error) { - authority.mu.Lock() - defer authority.mu.Unlock() - if authority.record.Transaction.ID == "" || authority.record.Transaction.ID != transactionID { - return decisionhttp.WorkflowRecord{}, fmt.Errorf("workflow %s not found", transactionID) - } - return authority.record, nil -} - -func (authority *testApprovalAuthority) statusHTTP(writer http.ResponseWriter, request *http.Request) { - record, err := authority.status(request.URL.Query().Get("transaction_id")) - if err != nil { - http.Error(writer, "workflow not found", http.StatusNotFound) - return - } - writeApprovalTestJSON(writer, record) -} - -func (authority *testApprovalAuthority) vote(transactionID string, vote decision.ApprovalVote) (decisionhttp.WorkflowRecord, error) { - authority.mu.Lock() - defer authority.mu.Unlock() - if authority.record.Transaction.ID == "" || authority.record.Transaction.ID != transactionID { - return decisionhttp.WorkflowRecord{}, fmt.Errorf("workflow %s not found", transactionID) - } - certificate, err := decision.NewApprovalCertificate( - authority.record.Transaction, []decision.ApprovalVote{vote}, - map[string]ed25519.PublicKey{"approval-key": authority.approvalPublic}, authority.decisionPublic, - time.Now().UTC(), "test-authority", "authority-key", - ) - if err == nil { - err = certificate.Sign(authority.decisionPrivate) - } - if err != nil { - return decisionhttp.WorkflowRecord{}, err - } - authority.record.Votes = []decision.ApprovalVote{vote} - authority.record.Certificate = &certificate - return authority.record, nil -} - -func (authority *testApprovalAuthority) execute(writer http.ResponseWriter, request *http.Request) { - var envelope decisionhttp.WorkflowExecuteEnvelope - if request.Method != http.MethodPost || json.NewDecoder(request.Body).Decode(&envelope) != nil { - http.Error(writer, "invalid execution", http.StatusBadRequest) - return - } - authority.mu.Lock() - defer authority.mu.Unlock() - if authority.record.Transaction.ID != envelope.TransactionID || authority.record.Certificate == nil { - http.Error(writer, "workflow not approved", http.StatusConflict) - return - } - if err := decision.VerifyApprovedExecution( - envelope.Intent, authority.record.Transaction, *authority.record.Certificate, authority.record.Votes, - map[string]ed25519.PublicKey{"approval-key": authority.approvalPublic}, authority.decisionPublic, time.Now().UTC(), - ); err != nil { - http.Error(writer, "invalid approved execution", http.StatusConflict) - return - } - intentHash, err := envelope.Intent.Hash() - if err != nil { - http.Error(writer, "invalid execution intent", http.StatusBadRequest) - return - } - certificateHash, err := authority.record.Certificate.Hash() - if err != nil { - http.Error(writer, "invalid approval certificate", http.StatusInternalServerError) - return - } - now := time.Now().UTC() - expiresAt := now.Add(time.Minute).Unix() - if envelope.Intent.ExpiresAt < expiresAt { - expiresAt = envelope.Intent.ExpiresAt - } - if authority.record.Certificate.ExpiresAt < expiresAt { - expiresAt = authority.record.Certificate.ExpiresAt - } - result := decision.Decision{ - Version: decision.SchemaVersion, ID: "approved-" + intentHash[:32], IntentHash: intentHash, - TenantID: envelope.Intent.TenantID, AgentID: envelope.Intent.AgentID, Outcome: authority.record.Certificate.Outcome, - Reasons: []string{"workflow:" + certificateHash}, Constraints: append([]decision.Constraint(nil), authority.record.Certificate.Constraints...), - PolicyRevision: authority.record.Certificate.PolicyRevision, RevocationEpoch: authority.record.Certificate.RevocationEpoch, - ProviderID: "test-authority", IssuedAt: now.Unix(), ExpiresAt: expiresAt, KeyID: "authority-key", - } - if err := result.Sign(authority.decisionPrivate); err != nil { - http.Error(writer, "sign approved decision", http.StatusInternalServerError) - return - } - authority.record.ConsumedIntentID = intentHash - authority.record.ConsumedDecision = &result - writeApprovalTestJSON(writer, result) -} - -func writeApprovalTestJSON(writer http.ResponseWriter, value any) { - writer.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(writer).Encode(value) -} - -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.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 { - 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) - - approvalAuthority := &testApprovalAuthority{ - decisionPrivate: fixture.decisionPrivate, - decisionPublic: fixture.decisionPrivate.Public().(ed25519.PublicKey), - approvalPublic: approvalPublic, - } - mux := http.NewServeMux() - mux.HandleFunc("/v1/authorize", approvalAuthority.authorize) - mux.HandleFunc("/v1/workflow/begin", approvalAuthority.begin) - mux.HandleFunc("/v1/workflow/execute", approvalAuthority.execute) - mux.HandleFunc("/v1/workflow-status", approvalAuthority.statusHTTP) - 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 := approvalAuthority.status(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 := approvalAuthority.vote(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) != 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 { - 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) - } - 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) { - 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_apps.go b/internal/enterprisecontrol/fleet_apps.go deleted file mode 100644 index 2865a06e..00000000 --- a/internal/enterprisecontrol/fleet_apps.go +++ /dev/null @@ -1,462 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package enterprisecontrol - -import ( - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - "time" - - appmanifest "github.com/pilot-protocol/app-store/pkg/manifest" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" -) - -// AppsConfig enables managed app reconciliation. It is opt-in for the same -// reason fleet state sync is: an unmanaged node must never acquire software -// because some authority asked it to. -type AppsConfig struct { - Enabled bool `json:"enabled,omitempty"` - InstallRoot string `json:"install_root,omitempty"` - StagingRoot string `json:"staging_root,omitempty"` - InstallerPath string `json:"installer_path,omitempty"` - ReconcileIntervalSeconds int64 `json:"reconcile_interval_seconds,omitempty"` -} - -// AppReconcileResult is bounded operational information for daemon logs. App -// identifiers are catalogue-public, so they may cross this boundary; local -// paths and manifest contents may not. -type AppReconcileResult struct { - Desired int - Installed int - Staged int - Removed int - Failed int -} - -// AppInstaller performs the actual bundle fetch, verification, and extraction. -// -// It is an interface because the verified install path currently lives in -// pilotctl's `package main` and cannot be linked into the daemon. Reconciliation -// logic is therefore testable without a real network or a real pilotctl binary, -// and the concrete implementation can later be swapped for an extracted -// library without touching anything here. -type AppInstaller interface { - // Install places appID at the requested version beneath root, performing - // the same catalogue signature and sha256 checks an operator would get at - // the keyboard. - Install(ctx context.Context, appID, version, root string) error - // Remove deletes appID from root. Removing an app that is not present is - // not an error. - Remove(ctx context.Context, appID, root string) error -} - -// PilotctlInstaller drives the pilotctl binary that ships beside the daemon. -// -// Shelling out is deliberate rather than convenient: pilotctl owns the only -// implementation of the catalogue trust chain (publisher signature, per-platform -// bundle pin, sha256 verification, sideload clamping). Reimplementing that here -// would create a second, subtly different verifier — the one outcome that must -// not happen for a security boundary. -type PilotctlInstaller struct { - BinaryPath string - Timeout time.Duration -} - -func (installer PilotctlInstaller) timeout() time.Duration { - if installer.Timeout > 0 { - return installer.Timeout - } - return 10 * time.Minute -} - -func (installer PilotctlInstaller) Install(ctx context.Context, appID, version, root string) error { - ctx, cancel := context.WithTimeout(ctx, installer.timeout()) - defer cancel() - // --force lets an install replace a wrong-version copy in place; the - // catalogue signature and sha256 gates still run either way. - // The pinned version is passed through so the node installs what the - // operator approved. pilotctl fails closed when the catalogue has moved - // on, which surfaces as version_unavailable rather than silently - // installing whatever release happens to be current. - arguments := []string{"appstore", "install", appID, "--force"} - if strings.TrimSpace(version) != "" { - arguments = append(arguments, "--version", version) - } - // #nosec G204 -- BinaryPath is the pilotctl shipped beside this daemon, and - // every argument originates in an authority-signed desired document that - // readDesiredApps has already run through FleetAppsDocument.Validate. That - // bounds the app id to fleetAppIDPattern and the version to - // fleetAppVersionPattern, neither of which admits a shell metacharacter or - // a leading dash, and CommandContext invokes no shell. - command := exec.CommandContext(ctx, installer.BinaryPath, arguments...) - command.Env = append(os.Environ(), "PILOT_APPSTORE_ROOT="+root) - output, err := command.CombinedOutput() - if err != nil { - return fmt.Errorf("install %s: %w: %s", appID, err, boundedInstallerOutput(output)) - } - return nil -} - -func (installer PilotctlInstaller) Remove(ctx context.Context, appID, root string) error { - target := filepath.Join(root, appID) - if _, err := os.Stat(target); os.IsNotExist(err) { - return nil - } - ctx, cancel := context.WithTimeout(ctx, installer.timeout()) - defer cancel() - // #nosec G204 -- same provenance as Install: a signed, validated app id - // matching fleetAppIDPattern, passed to the adjacent pilotctl without a shell. - command := exec.CommandContext(ctx, installer.BinaryPath, "appstore", "uninstall", appID, "--yes") - command.Env = append(os.Environ(), "PILOT_APPSTORE_ROOT="+root) - output, err := command.CombinedOutput() - if err != nil { - return fmt.Errorf("uninstall %s: %w: %s", appID, err, boundedInstallerOutput(output)) - } - return nil -} - -// boundedInstallerOutput keeps a failing subprocess's tail for the daemon log -// without letting an unbounded child write flood it. -func boundedInstallerOutput(output []byte) string { - const limit = 512 - text := strings.TrimSpace(string(output)) - if len(text) > limit { - text = text[len(text)-limit:] - } - return strings.ReplaceAll(text, "\n", " ") -} - -// HasAppReconcile reports whether this node reconciles managed apps. -// -// It requires the state mirror rather than the full fleet control channel: -// the desired document is delivered as a state mutation and then read from -// disk, so reconciliation is correct even during a spell when the authority is -// unreachable. Config load already refuses to enable apps without state sync. -// ensureSecureDirectory creates path when absent, then applies the same -// ownership and permission checks secureDirectory enforces. Creation is -// deliberately here rather than at adoption: the roots are runtime-owned, and a -// node whose apps directory is removed between reconciles must recover rather -// than wedge. -func ensureSecureDirectory(path string) error { - if err := os.MkdirAll(path, 0o700); err != nil { - return err - } - return secureDirectory(path) -} - -func (runtime *Runtime) HasAppReconcile() bool { - return runtime != nil && runtime.appsEnabled && runtime.fleetStateEnabled && runtime.fleetStateRoot != "" -} - -func (runtime *Runtime) AppReconcileInterval() time.Duration { - if !runtime.HasAppReconcile() { - return 0 - } - return runtime.appsInterval -} - -// ReconcileApps converges the node's installed apps toward the authority's -// desired set and republishes what it observes. -// -// The two-root design is the grant boundary. An app whose declared grants the -// tenant has not accepted is installed into the staging root, which the -// supervisor does not scan — so its binary exists, its manifest can be read and -// reported, and it cannot run. Promotion into the live install root happens -// only once the desired document carries an acceptance covering every grant the -// manifest declares. A catalogue that later widens an app's grants demotes it -// back to staging on the next pass rather than silently gaining capability. -func (runtime *Runtime) ReconcileApps(ctx context.Context, installer AppInstaller) (AppReconcileResult, error) { - if !runtime.HasAppReconcile() { - return AppReconcileResult{}, fmt.Errorf("enterprise control: app reconciliation is not configured") - } - if installer == nil { - return AppReconcileResult{}, fmt.Errorf("enterprise control: app installer is required") - } - runtime.appsMu.Lock() - defer runtime.appsMu.Unlock() - - runtime.mu.Lock() - tenantID, agentID := runtime.tenantID, runtime.rolloutAgentID - stateRoot, installRoot, stagingRoot := runtime.fleetStateRoot, runtime.appsInstallRoot, runtime.appsStagingRoot - runtime.mu.Unlock() - - desired, err := readDesiredApps(filepath.Join(stateRoot, authority.FleetAppsDocumentPath), tenantID, agentID) - if err != nil { - return AppReconcileResult{}, err - } - // Create both roots before validating them. Nothing else owns their - // creation: the supervisor makes the install root only when it starts with - // apps present, and the staging root has no other creator at all, so a - // freshly adopted node would fail this check on every tick forever. 0o700 - // is what secureDirectory then demands. - if err := ensureSecureDirectory(installRoot); err != nil { - return AppReconcileResult{}, fmt.Errorf("enterprise control: app install root: %w", err) - } - if err := ensureSecureDirectory(stagingRoot); err != nil { - return AppReconcileResult{}, fmt.Errorf("enterprise control: app staging root: %w", err) - } - - result := AppReconcileResult{Desired: len(desired.Desired)} - observed := make([]authority.FleetAppState, 0, len(desired.Desired)) - wanted := make(map[string]struct{}, len(desired.Desired)) - now := time.Now().UTC() - - for _, spec := range desired.Desired { - wanted[spec.ID] = struct{}{} - state := runtime.reconcileOneApp(ctx, installer, spec, installRoot, stagingRoot, now) - switch state.Status { - case authority.FleetAppInstalled: - result.Installed++ - case authority.FleetAppGrantBlocked: - result.Staged++ - case authority.FleetAppFailed: - result.Failed++ - } - observed = append(observed, state) - } - - // Anything this node installed under management but no longer wants is - // withdrawn from both roots. Apps a local operator installed by hand are - // deliberately untouched: management adds and removes what it was asked - // to, and does not assert ownership of the whole install root. - for _, appID := range managedAppIDs(stagingRoot) { - if _, keep := wanted[appID]; keep { - continue - } - if err := installer.Remove(ctx, appID, stagingRoot); err == nil { - result.Removed++ - } - } - for _, appID := range previouslyManaged(runtime.appsManaged, wanted) { - if err := installer.Remove(ctx, appID, installRoot); err == nil { - result.Removed++ - } - } - runtime.appsManaged = wanted - - sort.Slice(observed, func(i, j int) bool { return observed[i].ID < observed[j].ID }) - report := authority.FleetAppsReport{ - Version: authority.FleetAppsVersion, TenantID: tenantID, AgentID: agentID, - Apps: observed, ObservedAt: now.Unix(), - } - if err := writeObservedApps(filepath.Join(stateRoot, authority.FleetAppsReportPath), report); err != nil { - return result, err - } - return result, nil -} - -func (runtime *Runtime) reconcileOneApp(ctx context.Context, installer AppInstaller, spec authority.FleetAppSpec, installRoot, stagingRoot string, now time.Time) authority.FleetAppState { - state := authority.FleetAppState{ID: spec.ID, Version: spec.Version, ObservedAt: now.Unix()} - - live, liveErr := readAppManifest(filepath.Join(installRoot, spec.ID)) - staged, stagedErr := readAppManifest(filepath.Join(stagingRoot, spec.ID)) - - // Already live at the right version with an acceptance that still covers - // what it declares: nothing to do. - if liveErr == nil && live.AppVersion == spec.Version { - declared := manifestGrants(live) - state.DeclaredGrants, state.BinarySHA256 = declared, live.Binary.SHA256 - if authority.GrantsCovered(declared, spec.AcceptedGrants) { - state.Status = authority.FleetAppInstalled - return state - } - // Acceptance no longer covers the manifest. Demote rather than let a - // widened grant set keep running. - if err := installer.Remove(ctx, spec.ID, installRoot); err != nil { - state.Status, state.Detail = authority.FleetAppFailed, "demote_failed" - return state - } - liveErr, live = fmt.Errorf("demoted"), appmanifest.Manifest{} - } - - // Ensure a staged copy at the requested version exists so the manifest — - // the only truthful source of grants — can be read. - if stagedErr != nil || staged.AppVersion != spec.Version { - if err := installer.Install(ctx, spec.ID, spec.Version, stagingRoot); err != nil { - state.Status, state.Detail = authority.FleetAppFailed, "install_failed" - return state - } - staged, stagedErr = readAppManifest(filepath.Join(stagingRoot, spec.ID)) - if stagedErr != nil { - state.Status, state.Detail = authority.FleetAppFailed, "manifest_unreadable" - return state - } - } - - declared := manifestGrants(staged) - state.DeclaredGrants, state.BinarySHA256 = declared, staged.Binary.SHA256 - if staged.AppVersion != "" { - state.Version = staged.AppVersion - } - - if !authority.GrantsCovered(declared, spec.AcceptedGrants) { - // Held deliberately: installed, readable, reported, not running. - state.Status, state.Detail = authority.FleetAppGrantBlocked, "awaiting_grant_acceptance" - return state - } - - // Accepted — promote into the supervisor's scan root. - if err := installer.Install(ctx, spec.ID, spec.Version, installRoot); err != nil { - state.Status, state.Detail = authority.FleetAppFailed, "promote_failed" - return state - } - if err := installer.Remove(ctx, spec.ID, stagingRoot); err != nil { - // A leftover staged copy is inert; it must not fail the reconcile. - state.Detail = "staging_cleanup_deferred" - } - state.Status = authority.FleetAppInstalled - return state -} - -func readDesiredApps(path, tenantID, agentID string) (authority.FleetAppsDocument, error) { - var document authority.FleetAppsDocument - raw, err := os.ReadFile(path) // #nosec G304 -- path is built from the runtime's own confined state root. - if os.IsNotExist(err) { - // No desired set is a valid state meaning "manage no apps here". - return authority.FleetAppsDocument{Version: authority.FleetAppsVersion, TenantID: tenantID, AgentID: agentID}, nil - } - if err != nil { - return document, fmt.Errorf("enterprise control: read desired apps: %w", err) - } - if err := json.Unmarshal(raw, &document); err != nil { - return document, fmt.Errorf("enterprise control: parse desired apps: %w", err) - } - if err := document.Validate(); err != nil { - return authority.FleetAppsDocument{}, err - } - // The document arrives inside a signed, revision-fenced mutation, but it - // then sits on local disk. Re-checking that it still addresses this node - // costs nothing and refuses a file copied from another machine. - if document.TenantID != tenantID || document.AgentID != agentID { - return authority.FleetAppsDocument{}, fmt.Errorf("enterprise control: desired apps document addresses another node") - } - return document, nil -} - -// writeObservedApps republishes the inventory only when something an operator -// would care about actually changed. -// -// This is not an optimization. The report lives inside the fleet state mirror, -// so every rewrite bumps the node's state revision — and the console fences its -// mutations on that revision. Refreshing a timestamp every reconcile tick would -// invalidate an operator's in-flight install before they could confirm it. -func writeObservedApps(path string, report authority.FleetAppsReport) error { - if err := report.Validate(); err != nil { - return err - } - if existing, err := os.ReadFile(path); err == nil { // #nosec G304 -- confined state root. - var previous authority.FleetAppsReport - if json.Unmarshal(existing, &previous) == nil && sameObservedApps(previous, report) { - return nil - } - } - encoded, err := json.MarshalIndent(report, "", " ") - if err != nil { - return err - } - return atomicWriteSecureBytes(path, append(encoded, '\n')) -} - -// sameObservedApps compares two inventories ignoring observation timestamps, -// which advance on every tick regardless of whether anything happened. -func sameObservedApps(previous, current authority.FleetAppsReport) bool { - if previous.Version != current.Version || previous.TenantID != current.TenantID || - previous.AgentID != current.AgentID || len(previous.Apps) != len(current.Apps) { - return false - } - for index := range current.Apps { - before, after := previous.Apps[index], current.Apps[index] - if before.ID != after.ID || before.Version != after.Version || before.Status != after.Status || - before.Detail != after.Detail || before.BinarySHA256 != after.BinarySHA256 || - len(before.DeclaredGrants) != len(after.DeclaredGrants) { - return false - } - for grantIndex := range after.DeclaredGrants { - if before.DeclaredGrants[grantIndex] != after.DeclaredGrants[grantIndex] { - return false - } - } - } - return true -} - -func readAppManifest(directory string) (appmanifest.Manifest, error) { - raw, err := os.ReadFile(filepath.Join(directory, "manifest.json")) // #nosec G304 -- directory is confined to a managed app root. - if err != nil { - return appmanifest.Manifest{}, err - } - parsed, err := appmanifest.Parse(raw) - if err != nil { - return appmanifest.Manifest{}, err - } - if errs := parsed.Validate(); len(errs) > 0 { - return appmanifest.Manifest{}, fmt.Errorf("manifest validation: %v", errs[0]) - } - return *parsed, nil -} - -func manifestGrants(parsed appmanifest.Manifest) []authority.FleetAppGrant { - grants := make([]authority.FleetAppGrant, 0, len(parsed.Grants)) - for _, grant := range parsed.Grants { - grants = append(grants, authority.FleetAppGrant{Cap: grant.Cap, Target: grant.Target}) - } - sort.Slice(grants, func(i, j int) bool { - if grants[i].Cap != grants[j].Cap { - return grants[i].Cap < grants[j].Cap - } - return grants[i].Target < grants[j].Target - }) - return grants -} - -// managedAppIDs lists app directories under a root the runtime fully owns. -func managedAppIDs(root string) []string { - entries, err := os.ReadDir(root) - if err != nil { - return nil - } - ids := make([]string, 0, len(entries)) - for _, entry := range entries { - if entry.IsDir() && !strings.HasPrefix(entry.Name(), ".") { - ids = append(ids, entry.Name()) - } - } - sort.Strings(ids) - return ids -} - -// previouslyManaged returns the apps this runtime installed on an earlier pass -// that the authority no longer wants. Tracking what management placed is what -// keeps an operator's hand-installed apps out of scope for removal. -func previouslyManaged(managed map[string]struct{}, wanted map[string]struct{}) []string { - stale := make([]string, 0, len(managed)) - for appID := range managed { - if _, keep := wanted[appID]; !keep { - stale = append(stale, appID) - } - } - sort.Strings(stale) - return stale -} - -// withinDirectory reports whether candidate sits inside parent. It is used to -// keep the staging root outside the supervisor's scan root, so the guard has -// to resist a relative path that climbs back in. -func withinDirectory(parent, candidate string) bool { - absParent, parentErr := filepath.Abs(parent) - absCandidate, candidateErr := filepath.Abs(candidate) - if parentErr != nil || candidateErr != nil { - return false - } - relative, err := filepath.Rel(absParent, absCandidate) - if err != nil { - return false - } - return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) -} diff --git a/internal/enterprisecontrol/fleet_apps_gate_test.go b/internal/enterprisecontrol/fleet_apps_gate_test.go deleted file mode 100644 index e51ba8a4..00000000 --- a/internal/enterprisecontrol/fleet_apps_gate_test.go +++ /dev/null @@ -1,133 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package enterprisecontrol - -import ( - "context" - "errors" - "os" - "path/filepath" - "testing" - "time" - - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" -) - -// HasAppReconcile is the gate that decides whether a managed node ever acts on -// the App Store's desired document. It shipped defaulting to false with nothing -// in the adoption path able to turn it on, so the console queued installs that -// no node would ever perform. These cases pin the contract adoption must -// satisfy, including the fleet-state dependency: both the desired document and -// the observed report travel as fleet state, so apps without it would accept -// the option and then silently never reconcile. -func TestHasAppReconcileRequiresAppsAndFleetState(t *testing.T) { - for _, testCase := range []struct { - name string - apps, fleetState bool - stateRoot string - want bool - }{ - {name: "apps and fleet state", apps: true, fleetState: true, stateRoot: "/tmp/state", want: true}, - {name: "apps without fleet state", apps: true, fleetState: false, stateRoot: "/tmp/state", want: false}, - {name: "apps with no state root", apps: true, fleetState: true, stateRoot: "", want: false}, - {name: "fleet state without apps", apps: false, fleetState: true, stateRoot: "/tmp/state", want: false}, - {name: "neither", apps: false, fleetState: false, stateRoot: "", want: false}, - } { - t.Run(testCase.name, func(t *testing.T) { - runtime := &Runtime{appsEnabled: testCase.apps, fleetStateEnabled: testCase.fleetState, fleetStateRoot: testCase.stateRoot} - if got := runtime.HasAppReconcile(); got != testCase.want { - t.Fatalf("HasAppReconcile()=%v want %v", got, testCase.want) - } - }) - } - // A nil runtime must not panic the daemon's startup check. - var absent *Runtime - if absent.HasAppReconcile() { - t.Fatal("nil runtime reported app reconcile") - } -} - -// An enabled reconciler with a zero interval would spin as fast as the loop -// allows, so the interval must always be positive once the gate is open. -func TestAppReconcileIntervalIsPositiveWhenEnabled(t *testing.T) { - runtime := &Runtime{appsEnabled: true, fleetStateEnabled: true, fleetStateRoot: "/tmp/state", appsInterval: 30_000_000_000} - if runtime.AppReconcileInterval() <= 0 { - t.Fatal("enabled reconciler reported a non-positive interval") - } - disabled := &Runtime{} - if disabled.AppReconcileInterval() != 0 { - t.Fatal("disabled reconciler reported an interval") - } -} - -// recordingInstaller captures the arguments the reconciler would run. -type recordingInstaller struct { - installs []string - fail error -} - -func (installer *recordingInstaller) Install(_ context.Context, appID, version, root string) error { - installer.installs = append(installer.installs, appID+"@"+version) - return installer.fail -} - -func (installer *recordingInstaller) Remove(context.Context, string, string) error { return nil } - -// The desired document pins the version the operator approved. It must reach -// the installer, or the node silently installs whatever the catalogue happens -// to carry at reconcile time -- software nobody approved. -func TestReconcilePassesTheApprovedVersionToTheInstaller(t *testing.T) { - installer := &recordingInstaller{fail: errors.New("install refused")} - runtime := &Runtime{appsEnabled: true, fleetStateEnabled: true, fleetStateRoot: t.TempDir()} - state := runtime.reconcileOneApp( - context.Background(), installer, - authority.FleetAppSpec{ID: "io.pilot.example", Version: "1.2.3"}, - filepath.Join(t.TempDir(), "apps"), filepath.Join(t.TempDir(), "pending"), - time.Now(), - ) - if len(installer.installs) == 0 { - t.Fatal("reconcile never attempted an install") - } - if installer.installs[0] != "io.pilot.example@1.2.3" { - t.Fatalf("installer received %q, losing the approved version pin", installer.installs[0]) - } - if state.Status != authority.FleetAppFailed { - t.Fatalf("a refused install must report failed, got %q", state.Status) - } -} - -// A freshly adopted node has neither app root on disk: the supervisor creates -// the install root only when it starts with apps already present, and nothing -// creates the staging root at all. Reconcile therefore has to create both, or -// it fails on every tick forever and no app can ever be installed. Found by -// running the real end-to-end install against a newly adopted node. -func TestReconcileCreatesBothAppRootsOnAFreshNode(t *testing.T) { - home := t.TempDir() - installRoot := filepath.Join(home, ".pilot", "apps") - stagingRoot := filepath.Join(home, ".pilot", "apps-pending") - - for _, root := range []string{installRoot, stagingRoot} { - if _, err := os.Stat(root); !os.IsNotExist(err) { - t.Fatalf("precondition: %s already exists", root) - } - if err := ensureSecureDirectory(root); err != nil { - t.Fatalf("ensureSecureDirectory(%s): %v", root, err) - } - info, err := os.Stat(root) - if err != nil { - t.Fatalf("%s was not created: %v", root, err) - } - if !info.IsDir() { - t.Fatalf("%s is not a directory", root) - } - // secureDirectory rejects group- or world-writable roots, so creation - // must not hand back something it will then refuse. - if info.Mode().Perm()&0o022 != 0 { - t.Fatalf("%s created group/world writable: %v", root, info.Mode().Perm()) - } - // Idempotent: a second reconcile tick must not fail. - if err := ensureSecureDirectory(root); err != nil { - t.Fatalf("second call on %s failed: %v", root, err) - } - } -} diff --git a/internal/enterprisecontrol/fleet_apps_test.go b/internal/enterprisecontrol/fleet_apps_test.go deleted file mode 100644 index b2936fd4..00000000 --- a/internal/enterprisecontrol/fleet_apps_test.go +++ /dev/null @@ -1,471 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package enterprisecontrol - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "testing" - "time" - - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" -) - -// fakeInstaller stands in for the pilotctl subprocess. It plants a manifest -// declaring whatever grants the test wants, which is the only thing the -// reconciler reads out of an installed bundle. -type fakeInstaller struct { - grants map[string][]authority.FleetAppGrant - version map[string]string - failFor map[string]bool - installLog []string - removeLog []string -} - -func newFakeInstaller() *fakeInstaller { - return &fakeInstaller{ - grants: map[string][]authority.FleetAppGrant{}, - version: map[string]string{}, - failFor: map[string]bool{}, - } -} - -func (installer *fakeInstaller) Install(_ context.Context, appID, version, root string) error { - installer.installLog = append(installer.installLog, appID+"@"+version+"->"+filepath.Base(root)) - if installer.failFor[appID] { - return fmt.Errorf("simulated install failure") - } - if planted, ok := installer.version[appID]; ok { - version = planted - } - directory := filepath.Join(root, appID) - if err := os.MkdirAll(directory, 0o700); err != nil { - return err - } - grants := make([]map[string]string, 0, len(installer.grants[appID])) - for _, grant := range installer.grants[appID] { - grants = append(grants, map[string]string{"cap": grant.Cap, "target": grant.Target}) - } - manifest := map[string]any{ - "manifest_version": 1, - "id": appID, - "app_version": version, - "name": "Test App", - "description": "A test app used by the reconciler unit tests.", - "binary": map[string]string{"runtime": "go", "path": "app", "sha256": "aa" + repeat("0", 62)}, - "grants": grants, - "store": map[string]string{"publisher": "ed25519:" + repeat("A", 44), "signature": repeat("B", 64)}, - } - encoded, err := json.Marshal(manifest) - if err != nil { - return err - } - if err := os.WriteFile(filepath.Join(directory, "manifest.json"), encoded, 0o600); err != nil { - return err - } - return os.WriteFile(filepath.Join(directory, "app"), []byte("#!/bin/sh\n"), 0o700) -} - -func (installer *fakeInstaller) Remove(_ context.Context, appID, root string) error { - installer.removeLog = append(installer.removeLog, appID+"<-"+filepath.Base(root)) - return os.RemoveAll(filepath.Join(root, appID)) -} - -func repeat(s string, n int) string { - out := "" - for i := 0; i < n; i++ { - out += s - } - return out -} - -func newAppsTestRuntime(t *testing.T) (*Runtime, string, string, string) { - t.Helper() - base := t.TempDir() - stateRoot := filepath.Join(base, "state") - installRoot := filepath.Join(base, "apps") - stagingRoot := filepath.Join(base, "apps-pending") - for _, directory := range []string{stateRoot, installRoot, stagingRoot} { - if err := os.MkdirAll(directory, 0o700); err != nil { - t.Fatal(err) - } - } - runtime := &Runtime{ - tenantID: "tenant-a", rolloutAgentID: "agent-a", - fleetStateEnabled: true, fleetStateRoot: stateRoot, - appsEnabled: true, appsInstallRoot: installRoot, appsStagingRoot: stagingRoot, - appsInterval: 30 * time.Second, appsManaged: map[string]struct{}{}, - } - return runtime, stateRoot, installRoot, stagingRoot -} - -func writeDesired(t *testing.T, stateRoot string, specs ...authority.FleetAppSpec) { - t.Helper() - document := authority.FleetAppsDocument{ - Version: authority.FleetAppsVersion, TenantID: "tenant-a", AgentID: "agent-a", - Desired: specs, Reason: "unit test desired set", IssuedAt: time.Now().Unix(), - } - document.Normalize() - if err := document.Validate(); err != nil { - t.Fatalf("desired document invalid: %v", err) - } - encoded, err := json.MarshalIndent(document, "", " ") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(stateRoot, authority.FleetAppsDocumentPath), append(encoded, '\n'), 0o600); err != nil { - t.Fatal(err) - } -} - -func readObserved(t *testing.T, stateRoot string) map[string]authority.FleetAppState { - t.Helper() - raw, err := os.ReadFile(filepath.Join(stateRoot, authority.FleetAppsReportPath)) - if err != nil { - t.Fatalf("read observed report: %v", err) - } - var report authority.FleetAppsReport - if err := json.Unmarshal(raw, &report); err != nil { - t.Fatal(err) - } - if err := report.Validate(); err != nil { - t.Fatalf("observed report invalid: %v", err) - } - states := make(map[string]authority.FleetAppState, len(report.Apps)) - for _, state := range report.Apps { - states[state.ID] = state - } - return states -} - -// An app the tenant has not reviewed must land in staging, never in the -// supervisor's scan root. This is the whole grant boundary. -func TestReconcileHoldsUnreviewedAppOutOfInstallRoot(t *testing.T) { - runtime, stateRoot, installRoot, stagingRoot := newAppsTestRuntime(t) - installer := newFakeInstaller() - installer.grants["io.pilot.duckdb"] = []authority.FleetAppGrant{{Cap: "fs.read", Target: "$APP/*"}} - - writeDesired(t, stateRoot, authority.FleetAppSpec{ - ID: "io.pilot.duckdb", Version: "1.5.4", ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), - }) - - result, err := runtime.ReconcileApps(context.Background(), installer) - if err != nil { - t.Fatalf("reconcile: %v", err) - } - if result.Staged != 1 || result.Installed != 0 { - t.Fatalf("expected one staged and none installed, got %+v", result) - } - if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { - t.Fatal("unreviewed app reached the supervisor's install root") - } - if _, err := os.Stat(filepath.Join(stagingRoot, "io.pilot.duckdb")); err != nil { - t.Fatalf("expected staged copy: %v", err) - } - state := readObserved(t, stateRoot)["io.pilot.duckdb"] - if state.Status != authority.FleetAppGrantBlocked { - t.Fatalf("status = %q, want %q", state.Status, authority.FleetAppGrantBlocked) - } - // The report must carry the grants so the console can show them. - if len(state.DeclaredGrants) != 1 || state.DeclaredGrants[0].Cap != "fs.read" { - t.Fatalf("declared grants not reported: %+v", state.DeclaredGrants) - } -} - -// Once the desired document accepts exactly what the manifest declares, the -// app is promoted into the install root and the staged copy is cleaned up. -func TestReconcilePromotesAppOnceGrantsAccepted(t *testing.T) { - runtime, stateRoot, installRoot, stagingRoot := newAppsTestRuntime(t) - installer := newFakeInstaller() - grants := []authority.FleetAppGrant{{Cap: "fs.read", Target: "$APP/*"}, {Cap: "audit.log", Target: "*"}} - installer.grants["io.pilot.duckdb"] = grants - - writeDesired(t, stateRoot, authority.FleetAppSpec{ - ID: "io.pilot.duckdb", Version: "1.5.4", AcceptedGrants: grants, - ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), - }) - - result, err := runtime.ReconcileApps(context.Background(), installer) - if err != nil { - t.Fatalf("reconcile: %v", err) - } - if result.Installed != 1 || result.Staged != 0 { - t.Fatalf("expected one installed, got %+v", result) - } - if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); err != nil { - t.Fatalf("accepted app missing from install root: %v", err) - } - if _, err := os.Stat(filepath.Join(stagingRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { - t.Fatal("staged copy was not cleaned up after promotion") - } - if state := readObserved(t, stateRoot)["io.pilot.duckdb"]; state.Status != authority.FleetAppInstalled { - t.Fatalf("status = %q, want installed", state.Status) - } -} - -// A partial acceptance must not promote. Covering one of two declared grants -// is not covering the manifest. -func TestReconcileRefusesPartialGrantAcceptance(t *testing.T) { - runtime, stateRoot, installRoot, _ := newAppsTestRuntime(t) - installer := newFakeInstaller() - installer.grants["io.pilot.duckdb"] = []authority.FleetAppGrant{ - {Cap: "fs.read", Target: "$APP/*"}, - {Cap: "net.dial", Target: "api.example.com"}, - } - - writeDesired(t, stateRoot, authority.FleetAppSpec{ - ID: "io.pilot.duckdb", Version: "1.5.4", - AcceptedGrants: []authority.FleetAppGrant{{Cap: "fs.read", Target: "$APP/*"}}, - ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), - }) - - if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { - t.Fatalf("reconcile: %v", err) - } - if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { - t.Fatal("app with an uncovered grant was promoted") - } - if state := readObserved(t, stateRoot)["io.pilot.duckdb"]; state.Status != authority.FleetAppGrantBlocked { - t.Fatalf("status = %q, want grant_blocked", state.Status) - } -} - -// If a catalogue republish widens an app's grants, a node that already runs it -// must demote it rather than keep running the wider capability set. -func TestReconcileDemotesAppWhenGrantsWiden(t *testing.T) { - runtime, stateRoot, installRoot, stagingRoot := newAppsTestRuntime(t) - installer := newFakeInstaller() - original := []authority.FleetAppGrant{{Cap: "fs.read", Target: "$APP/*"}} - installer.grants["io.pilot.duckdb"] = original - - spec := authority.FleetAppSpec{ - ID: "io.pilot.duckdb", Version: "1.5.4", AcceptedGrants: original, - ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), - } - writeDesired(t, stateRoot, spec) - if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { - t.Fatalf("first reconcile: %v", err) - } - if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); err != nil { - t.Fatalf("expected app installed after first pass: %v", err) - } - - // The app is republished asking for more than was accepted. - installer.grants["io.pilot.duckdb"] = append(original, authority.FleetAppGrant{Cap: "proc.exec", Target: "/bin/sh"}) - if err := installer.Install(context.Background(), "io.pilot.duckdb", "1.5.4", installRoot); err != nil { - t.Fatal(err) - } - - if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { - t.Fatalf("second reconcile: %v", err) - } - if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { - t.Fatal("app with widened grants stayed in the install root") - } - if _, err := os.Stat(filepath.Join(stagingRoot, "io.pilot.duckdb")); err != nil { - t.Fatalf("demoted app should be staged for review: %v", err) - } - if state := readObserved(t, stateRoot)["io.pilot.duckdb"]; state.Status != authority.FleetAppGrantBlocked { - t.Fatalf("status = %q, want grant_blocked", state.Status) - } -} - -// Dropping an app from the desired set withdraws it from the node. -func TestReconcileRemovesAppDroppedFromDesiredSet(t *testing.T) { - runtime, stateRoot, installRoot, stagingRoot := newAppsTestRuntime(t) - installer := newFakeInstaller() - grants := []authority.FleetAppGrant{{Cap: "audit.log", Target: "*"}} - installer.grants["io.pilot.duckdb"] = grants - - writeDesired(t, stateRoot, authority.FleetAppSpec{ - ID: "io.pilot.duckdb", Version: "1.5.4", AcceptedGrants: grants, - ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), - }) - if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { - t.Fatalf("install pass: %v", err) - } - - writeDesired(t, stateRoot) - result, err := runtime.ReconcileApps(context.Background(), installer) - if err != nil { - t.Fatalf("removal pass: %v", err) - } - if result.Removed != 1 { - t.Fatalf("expected one removal, got %+v", result) - } - for _, root := range []string{installRoot, stagingRoot} { - if _, err := os.Stat(filepath.Join(root, "io.pilot.duckdb")); !os.IsNotExist(err) { - t.Fatalf("app still present under %s", root) - } - } -} - -// An app a local operator installed by hand is not management's to remove. -func TestReconcileLeavesUnmanagedAppsAlone(t *testing.T) { - runtime, stateRoot, installRoot, _ := newAppsTestRuntime(t) - installer := newFakeInstaller() - if err := installer.Install(context.Background(), "io.pilot.handrolled", "0.1.0", installRoot); err != nil { - t.Fatal(err) - } - writeDesired(t, stateRoot) - - if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { - t.Fatalf("reconcile: %v", err) - } - if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.handrolled")); err != nil { - t.Fatalf("hand-installed app was removed by management: %v", err) - } -} - -// A desired document addressed to another node must be refused even though it -// arrived through a signed channel — the file also sits on local disk. -func TestReconcileRefusesDocumentForAnotherNode(t *testing.T) { - runtime, stateRoot, _, _ := newAppsTestRuntime(t) - document := authority.FleetAppsDocument{ - Version: authority.FleetAppsVersion, TenantID: "tenant-a", AgentID: "agent-elsewhere", - Reason: "document copied from another machine", IssuedAt: time.Now().Unix(), - } - encoded, err := json.MarshalIndent(document, "", " ") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(stateRoot, authority.FleetAppsDocumentPath), encoded, 0o600); err != nil { - t.Fatal(err) - } - if _, err := runtime.ReconcileApps(context.Background(), newFakeInstaller()); err == nil { - t.Fatal("expected a document addressed to another node to be refused") - } -} - -// A missing desired document means "manage no apps here", not an error. -func TestReconcileTreatsMissingDocumentAsEmptyDesiredSet(t *testing.T) { - runtime, stateRoot, _, _ := newAppsTestRuntime(t) - result, err := runtime.ReconcileApps(context.Background(), newFakeInstaller()) - if err != nil { - t.Fatalf("reconcile: %v", err) - } - if result.Desired != 0 { - t.Fatalf("expected empty desired set, got %+v", result) - } - if _, err := os.Stat(filepath.Join(stateRoot, authority.FleetAppsReportPath)); err != nil { - t.Fatalf("an empty reconcile must still publish an inventory: %v", err) - } -} - -// A failing install is reported, not fatal, and must not leave the app in the -// install root. -func TestReconcileReportsInstallFailure(t *testing.T) { - runtime, stateRoot, installRoot, _ := newAppsTestRuntime(t) - installer := newFakeInstaller() - installer.failFor["io.pilot.duckdb"] = true - - writeDesired(t, stateRoot, authority.FleetAppSpec{ - ID: "io.pilot.duckdb", Version: "1.5.4", ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), - }) - result, err := runtime.ReconcileApps(context.Background(), installer) - if err != nil { - t.Fatalf("a failing app must not fail the whole reconcile: %v", err) - } - if result.Failed != 1 { - t.Fatalf("expected one failure, got %+v", result) - } - if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { - t.Fatal("failed install left an app in the install root") - } - if state := readObserved(t, stateRoot)["io.pilot.duckdb"]; state.Status != authority.FleetAppFailed { - t.Fatalf("status = %q, want failed", state.Status) - } -} - -func TestWithinDirectoryRejectsNestedStagingRoot(t *testing.T) { - if !withinDirectory("/var/pilot/apps", "/var/pilot/apps/pending") { - t.Fatal("nested staging root should be detected") - } - if withinDirectory("/var/pilot/apps", "/var/pilot/apps-pending") { - t.Fatal("sibling staging root must not be treated as nested") - } - if withinDirectory("/var/pilot/apps", "/var/pilot") { - t.Fatal("parent directory must not be treated as nested") - } -} - -// The inventory must not be rewritten when nothing changed. Every rewrite -// bumps the state revision the console fences its mutations on, so a ticking -// timestamp would invalidate an operator's in-flight install. -func TestReconcileDoesNotRewriteUnchangedInventory(t *testing.T) { - runtime, stateRoot, _, _ := newAppsTestRuntime(t) - installer := newFakeInstaller() - grants := []authority.FleetAppGrant{{Cap: "audit.log", Target: "*"}} - installer.grants["io.pilot.duckdb"] = grants - writeDesired(t, stateRoot, authority.FleetAppSpec{ - ID: "io.pilot.duckdb", Version: "1.5.4", AcceptedGrants: grants, - ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), - }) - - if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { - t.Fatalf("first reconcile: %v", err) - } - reportPath := filepath.Join(stateRoot, authority.FleetAppsReportPath) - first, err := os.ReadFile(reportPath) - if err != nil { - t.Fatal(err) - } - - // A later pass with identical results must leave the bytes untouched even - // though wall-clock time has advanced. - time.Sleep(1100 * time.Millisecond) - if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { - t.Fatalf("second reconcile: %v", err) - } - second, err := os.ReadFile(reportPath) - if err != nil { - t.Fatal(err) - } - if string(first) != string(second) { - t.Fatal("unchanged inventory was rewritten, which would churn the state revision") - } -} - -// A real change must still be published. -func TestReconcileRepublishesInventoryOnChange(t *testing.T) { - runtime, stateRoot, _, _ := newAppsTestRuntime(t) - installer := newFakeInstaller() - installer.grants["io.pilot.duckdb"] = []authority.FleetAppGrant{{Cap: "audit.log", Target: "*"}} - writeDesired(t, stateRoot, authority.FleetAppSpec{ - ID: "io.pilot.duckdb", Version: "1.5.4", ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), - }) - if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { - t.Fatalf("first reconcile: %v", err) - } - if status := readObserved(t, stateRoot)["io.pilot.duckdb"].Status; status != authority.FleetAppGrantBlocked { - t.Fatalf("expected grant_blocked, got %q", status) - } - - // Accept the grants; the inventory must now report installed. - writeDesired(t, stateRoot, authority.FleetAppSpec{ - ID: "io.pilot.duckdb", Version: "1.5.4", - AcceptedGrants: []authority.FleetAppGrant{{Cap: "audit.log", Target: "*"}}, - ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), - }) - if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { - t.Fatalf("second reconcile: %v", err) - } - if status := readObserved(t, stateRoot)["io.pilot.duckdb"].Status; status != authority.FleetAppInstalled { - t.Fatalf("expected installed after acceptance, got %q", status) - } -} - -// Cross-repo invariant: the console writes these paths through the state -// mutation channel, so the node's own protected-path guard must not refuse -// them. If someone renames a document to include "policy" or "trust", installs -// silently stop working. -func TestAppDocumentPathsAreNotProtected(t *testing.T) { - for _, path := range []string{authority.FleetAppsDocumentPath, authority.FleetAppsReportPath} { - if fleetMutationPathProtected(path) { - t.Fatalf("%q is refused by the node's protected-path guard", path) - } - } -} diff --git a/internal/enterprisecontrol/fleet_state.go b/internal/enterprisecontrol/fleet_state.go deleted file mode 100644 index fc0b3a43..00000000 --- a/internal/enterprisecontrol/fleet_state.go +++ /dev/null @@ -1,746 +0,0 @@ -// 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/pilotprotocol/internal/managedsdk/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 - // #nosec G304 -- filename is produced by WalkDir beneath the configured, owner-only fleet state root. - 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)) - // #nosec G304 -- same WalkDir-confined path as the hash pass above; only a bounded preview is read. - 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 - // #nosec G115 -- offset is initialized to zero and only assigned size-want where size >= want. - 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 - } - // #nosec G302 -- backupRoot is a directory; owner-only 0700 is the intended secure mode. - 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) { - // #nosec G304 -- filename has already passed confinedFleetPath beneath the private fleet state root. - 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 deleted file mode 100644 index af568d8f..00000000 --- a/internal/enterprisecontrol/fleet_state_test.go +++ /dev/null @@ -1,131 +0,0 @@ -// 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/pilotprotocol/internal/managedsdk/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 deleted file mode 100644 index f6e66cce..00000000 --- a/internal/enterprisecontrol/mandates.go +++ /dev/null @@ -1,78 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package enterprisecontrol - -import ( - "context" - "fmt" - "sync" - "time" - - "github.com/pilot-protocol/common/decision" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" -) - -// 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/internal/enterprisecontrol/zz_lifecycle_guard_test.go b/internal/enterprisecontrol/zz_lifecycle_guard_test.go deleted file mode 100644 index b11059cc..00000000 --- a/internal/enterprisecontrol/zz_lifecycle_guard_test.go +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package enterprisecontrol - -import ( - "path/filepath" - "testing" - - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/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") - } -} diff --git a/internal/managedsdk/README.md b/internal/managedsdk/README.md deleted file mode 100644 index 5b632ac0..00000000 --- a/internal/managedsdk/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Managed node SDK boundary - -This directory is the public, node-side half of Pilot's optional managed mode. -It contains signed wire contracts, local verification, deterministic policy -enforcement, rollout/fleet clients, and one-time adoption support needed by -`pilotctl` and `pilot-daemon`. - -It does **not** contain the hosted platform's tenant services, persistence, -semantic evaluator, billing, management UI, or account infrastructure. Those -remain in the private hosted-platform repository. - -It is also independent of `pilotprotocol-mcp`. The MCP package is an optional -harness adapter; it neither installs nor owns Pilot's node identity, managed -attachment, daemon, policy state, or fleet-control channel. diff --git a/internal/managedsdk/actioncontinuation/store.go b/internal/managedsdk/actioncontinuation/store.go deleted file mode 100644 index 1fbf21f8..00000000 --- a/internal/managedsdk/actioncontinuation/store.go +++ /dev/null @@ -1,490 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Package actioncontinuation stores local, payload-free approval suspension -// records and exact-once resume leases. The authority owns approval votes and -// certificates; this package owns only the node-side fact that a particular -// adapter action may be attempted at most once after approval. -package actioncontinuation - -import ( - "bytes" - "context" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "sync" - "time" - "unicode/utf8" - - "github.com/pilot-protocol/common/actionhook" - "github.com/pilot-protocol/common/fsutil" -) - -const ( - SchemaVersion uint16 = 1 - MaxRecordBytes = 64 << 10 -) - -var ( - ErrNotFound = errors.New("actioncontinuation: continuation not found") - ErrConflict = errors.New("actioncontinuation: continuation conflict") - ErrAlreadyClaimed = errors.New("actioncontinuation: continuation already claimed") - ErrExpired = errors.New("actioncontinuation: continuation expired") -) - -type State string - -const ( - StatePending State = "pending" - StateExecuting State = "executing" - StateSucceeded State = "succeeded" - StateFailed State = "failed" - StateInvalid State = "invalid" -) - -// Record contains no payload, prompt, secret, local path, or peer address. -// ResumeToken is adapter-local and never uploaded to the authority. -type Record struct { - Version uint16 `json:"version"` - ID string `json:"id"` - Fingerprint string `json:"fingerprint"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - ActionID string `json:"action_id"` - Action string `json:"action"` - Resource string `json:"resource"` - PayloadHash string `json:"payload_hash"` - AdapterID string `json:"adapter_id"` - ResumeToken string `json:"resume_token"` - InitialDecision actionhook.DecisionReference `json:"initial_decision"` - ApprovalTransaction string `json:"approval_transaction_id"` - State State `json:"state"` - CreatedAt int64 `json:"created_at"` - ExpiresAt int64 `json:"expires_at"` - AttemptedAt int64 `json:"attempted_at,omitempty"` - FinishedAt int64 `json:"finished_at,omitempty"` - FailureCode string `json:"failure_code,omitempty"` -} - -type Store struct { - directory string - mu sync.Mutex - now func() time.Time -} - -func Open(directory string) (*Store, error) { - if strings.TrimSpace(directory) == "" { - return nil, fmt.Errorf("actioncontinuation: directory is required") - } - absolute, err := filepath.Abs(filepath.Clean(directory)) - if err != nil { - return nil, err - } - if info, statErr := os.Lstat(absolute); statErr == nil { - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { - return nil, fmt.Errorf("actioncontinuation: path must be a real directory") - } - } else if !os.IsNotExist(statErr) { - return nil, statErr - } - if err := os.MkdirAll(absolute, 0o700); err != nil { - return nil, err - } - // #nosec G302 -- absolute is a directory; owner-only 0700 is the intended secure mode. - if err := os.Chmod(absolute, 0o700); err != nil { - return nil, err - } - return &Store{directory: absolute, now: time.Now}, nil -} - -// NewPending constructs the canonical continuation identity. ActionID is the -// original hook action ID for trace correlation; it is not used for lookup, -// because a later process creates a new envelope ID when resuming. -func NewPending(tenantID, agentID string, envelope actionhook.Envelope, reference actionhook.DecisionReference, transactionID string, expiresAt time.Time) (Record, error) { - record := Record{ - Version: SchemaVersion, TenantID: tenantID, AgentID: agentID, ActionID: envelope.ID, - Action: envelope.Action, Resource: envelope.Resource, PayloadHash: envelope.PayloadHash, - AdapterID: envelope.AdapterID, ResumeToken: envelope.ResumeToken, - InitialDecision: reference, ApprovalTransaction: transactionID, State: StatePending, - CreatedAt: envelope.CreatedAt, ExpiresAt: expiresAt.UTC().Unix(), - } - record.Fingerprint = Fingerprint(tenantID, agentID, envelope.Action, envelope.Resource, envelope.PayloadHash, envelope.AdapterID, envelope.ResumeToken) - record.ID = RecordID(record.Fingerprint, transactionID) - if err := record.Validate(); err != nil { - return Record{}, err - } - return record, nil -} - -// Fingerprint is deterministic for the exact side effect and local resume -// token. None of its inputs are recoverable from the resulting identifier. -func Fingerprint(tenantID, agentID, action, resource, payloadHash, adapterID, resumeToken string) string { - hash := sha256.New() - for _, value := range []string{tenantID, agentID, action, resource, payloadHash, adapterID, resumeToken} { - var length [8]byte - n := uint64(len(value)) - for index := 7; index >= 0; index-- { - length[index] = byte(n) - n >>= 8 - } - _, _ = hash.Write(length[:]) - _, _ = hash.Write([]byte(value)) - } - return hex.EncodeToString(hash.Sum(nil)) -} - -// RecordID adds the authority transaction identity, allowing a later, -// intentional repetition of the same side effect to open a new workflow after -// the prior one completed without overwriting its evidence. -func RecordID(fingerprint, transactionID string) string { - hash := sha256.New() - _, _ = hash.Write([]byte(fingerprint)) - _, _ = hash.Write([]byte{0}) - _, _ = hash.Write([]byte(transactionID)) - return hex.EncodeToString(hash.Sum(nil)) -} - -func (record Record) Validate() error { - if record.Version != SchemaVersion || !lowerHex(record.ID, 64) || !lowerHex(record.Fingerprint, 64) || !lowerHex(record.PayloadHash, 64) { - return fmt.Errorf("actioncontinuation: invalid version or hash identity") - } - expectedFingerprint := Fingerprint(record.TenantID, record.AgentID, record.Action, record.Resource, record.PayloadHash, record.AdapterID, record.ResumeToken) - if record.Fingerprint != expectedFingerprint || record.ID != RecordID(expectedFingerprint, record.ApprovalTransaction) { - return fmt.Errorf("actioncontinuation: non-canonical continuation identity") - } - for _, value := range []string{record.TenantID, record.AgentID, record.ActionID, record.Action, record.AdapterID, record.ApprovalTransaction} { - if !identifier(value, 128) { - return fmt.Errorf("actioncontinuation: invalid identifier") - } - } - if !text(record.Resource, 1024, false) || !text(record.ResumeToken, 1024, true) { - return fmt.Errorf("actioncontinuation: invalid resource or resume token") - } - switch record.State { - case StatePending: - if record.AttemptedAt != 0 || record.FinishedAt != 0 || record.FailureCode != "" { - return fmt.Errorf("actioncontinuation: pending continuation has terminal fields") - } - case StateExecuting: - if record.AttemptedAt <= 0 || record.FinishedAt != 0 || record.FailureCode != "" { - return fmt.Errorf("actioncontinuation: invalid executing continuation") - } - case StateSucceeded: - if record.AttemptedAt <= 0 || record.FinishedAt < record.AttemptedAt || record.FailureCode != "" { - return fmt.Errorf("actioncontinuation: invalid successful continuation") - } - case StateFailed, StateInvalid: - if record.FinishedAt <= 0 || !identifier(record.FailureCode, 128) { - return fmt.Errorf("actioncontinuation: invalid failed continuation") - } - default: - return fmt.Errorf("actioncontinuation: invalid state %q", record.State) - } - if record.CreatedAt <= 0 || record.ExpiresAt <= record.CreatedAt || record.ExpiresAt-record.CreatedAt > int64((7*24*time.Hour)/time.Second) { - return fmt.Errorf("actioncontinuation: invalid validity window") - } - return nil -} - -// PutPending uses an exclusive create, so two CLI processes cannot replace -// each other's approval transaction for the same exact action. -func (store *Store) PutPending(ctx context.Context, candidate Record) (Record, error) { - if err := ctx.Err(); err != nil { - return Record{}, err - } - if err := candidate.Validate(); err != nil || candidate.State != StatePending { - if err != nil { - return Record{}, err - } - return Record{}, ErrConflict - } - store.mu.Lock() - defer store.mu.Unlock() - path := store.recordPath(candidate.ID) - body, err := json.Marshal(candidate) - if err != nil { - return Record{}, err - } - // #nosec G304 -- path is beneath the owner-only store and its filename is a validated 64-hex record ID. - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if os.IsExist(err) { - existing, readErr := store.readLocked(candidate.ID) - if readErr != nil { - return Record{}, readErr - } - if sameImmutable(existing, candidate) { - return existing, nil - } - return Record{}, ErrConflict - } - if err != nil { - return Record{}, err - } - if _, err = file.Write(body); err == nil { - err = file.Sync() - } - closeErr := file.Close() - if err != nil { - _ = os.Remove(path) - return Record{}, err - } - if closeErr != nil { - return Record{}, closeErr - } - return candidate, nil -} - -func (store *Store) Get(ctx context.Context, id string) (Record, error) { - if err := ctx.Err(); err != nil { - return Record{}, err - } - store.mu.Lock() - defer store.mu.Unlock() - record, err := store.readLocked(id) - if err != nil { - return Record{}, err - } - if record.State == StatePending { - if _, statErr := os.Lstat(store.leasePath(id)); statErr == nil { - record.State = StateExecuting - record.AttemptedAt = record.CreatedAt - } - } - return record, nil -} - -// FindActive returns the one continuation that still controls an exact side -// effect. Successful records are historical and do not prevent a deliberate -// later repetition. Pending, executing, and failed records remain active; a -// failed/ambiguous execution therefore needs a different adapter resume token -// before it can be attempted again. -func (store *Store) FindActive(ctx context.Context, fingerprint string) (Record, error) { - if err := ctx.Err(); err != nil { - return Record{}, err - } - if !lowerHex(fingerprint, 64) { - return Record{}, ErrNotFound - } - store.mu.Lock() - defer store.mu.Unlock() - entries, err := os.ReadDir(store.directory) - if err != nil { - return Record{}, err - } - var found *Record - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { - continue - } - id := strings.TrimSuffix(entry.Name(), ".json") - record, readErr := store.readLocked(id) - if readErr != nil { - return Record{}, readErr - } - if record.Fingerprint != fingerprint || record.State == StateSucceeded || record.State == StateInvalid { - continue - } - if found != nil { - return Record{}, ErrConflict - } - copy := record - found = © - } - if found == nil { - return Record{}, ErrNotFound - } - if found.State == StatePending { - if _, statErr := os.Lstat(store.leasePath(found.ID)); statErr == nil { - found.State = StateExecuting - found.AttemptedAt = found.CreatedAt - } - } - return *found, nil -} - -// ClaimResume atomically publishes an exclusive lease before returning a -// permit. A crash after this point is fail-closed: the lease is intentionally -// never deleted, so the side effect cannot be attempted twice ambiguously. -func (store *Store) ClaimResume(ctx context.Context, id string) (Record, string, error) { - if err := ctx.Err(); err != nil { - return Record{}, "", err - } - store.mu.Lock() - defer store.mu.Unlock() - record, err := store.readLocked(id) - if err != nil { - return Record{}, "", err - } - now := store.now().UTC() - if now.Unix() >= record.ExpiresAt { - return Record{}, "", ErrExpired - } - if record.State != StatePending { - return Record{}, "", ErrAlreadyClaimed - } - var tokenBytes [32]byte - if _, err := rand.Read(tokenBytes[:]); err != nil { - return Record{}, "", err - } - token := hex.EncodeToString(tokenBytes[:]) - lease, err := os.OpenFile(store.leasePath(id), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if os.IsExist(err) { - return Record{}, "", ErrAlreadyClaimed - } - if err != nil { - return Record{}, "", err - } - if _, err = lease.WriteString(token); err == nil { - err = lease.Sync() - } - closeErr := lease.Close() - if err != nil { - return Record{}, "", err - } - if closeErr != nil { - return Record{}, "", closeErr - } - record.State = StateExecuting - record.AttemptedAt = now.Unix() - if err := store.writeLocked(record); err != nil { - // Keep the lease on write failure: ambiguity must fail closed. - return Record{}, "", err - } - return record, token, nil -} - -func (store *Store) Finish(ctx context.Context, id, leaseToken string, succeeded bool, failureCode string) (Record, error) { - if err := ctx.Err(); err != nil { - return Record{}, err - } - store.mu.Lock() - defer store.mu.Unlock() - lease, err := os.ReadFile(store.leasePath(id)) - if err != nil || !bytes.Equal(lease, []byte(leaseToken)) { - return Record{}, ErrConflict - } - record, err := store.readLocked(id) - if err != nil { - return Record{}, err - } - if record.State == StateSucceeded || record.State == StateFailed { - return record, nil - } - if record.State != StateExecuting { - return Record{}, ErrConflict - } - record.FinishedAt = store.now().UTC().Unix() - if succeeded { - record.State = StateSucceeded - record.FailureCode = "" - } else { - if !identifier(failureCode, 128) { - return Record{}, fmt.Errorf("actioncontinuation: failure code is required") - } - record.State = StateFailed - record.FailureCode = failureCode - } - if err := store.writeLocked(record); err != nil { - return Record{}, err - } - return record, nil -} - -func (store *Store) recordPath(id string) string { return filepath.Join(store.directory, id+".json") } -func (store *Store) leasePath(id string) string { return filepath.Join(store.directory, id+".lease") } - -func (store *Store) readLocked(id string) (Record, error) { - if !lowerHex(id, 64) { - return Record{}, ErrNotFound - } - path := store.recordPath(id) - info, err := os.Lstat(path) - if os.IsNotExist(err) { - return Record{}, ErrNotFound - } - if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Size() > MaxRecordBytes || info.Mode().Perm()&0o077 != 0 { - return Record{}, fmt.Errorf("actioncontinuation: unsafe continuation record") - } - // #nosec G304 -- path is beneath the owner-only store; ID, type, size, symlink status and permissions are checked above. - body, err := os.ReadFile(path) - if err != nil { - return Record{}, err - } - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.DisallowUnknownFields() - var record Record - if err := decoder.Decode(&record); err != nil { - return Record{}, err - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return Record{}, fmt.Errorf("actioncontinuation: trailing record data") - } - if err := record.Validate(); err != nil || record.ID != id { - if err != nil { - return Record{}, err - } - return Record{}, ErrConflict - } - return record, nil -} - -func (store *Store) writeLocked(record Record) error { - if err := record.Validate(); err != nil { - return err - } - body, err := json.Marshal(record) - if err != nil { - return err - } - return fsutil.AtomicWrite(store.recordPath(record.ID), body) -} - -func sameImmutable(first, second Record) bool { - return first.ID == second.ID && first.TenantID == second.TenantID && first.AgentID == second.AgentID && - first.Fingerprint == second.Fingerprint && - first.Action == second.Action && first.Resource == second.Resource && first.PayloadHash == second.PayloadHash && - first.AdapterID == second.AdapterID && first.ResumeToken == second.ResumeToken && - first.ApprovalTransaction == second.ApprovalTransaction -} - -func lowerHex(value string, length int) bool { - if len(value) != length || value != strings.ToLower(value) { - return false - } - _, err := hex.DecodeString(value) - return err == nil -} - -func identifier(value string, max int) bool { - if value == "" || len(value) > max || !utf8.ValidString(value) { - return false - } - for index, character := range value { - if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || - (character >= '0' && character <= '9') || (index > 0 && strings.ContainsRune("._:/@-", character)) { - continue - } - return false - } - return true -} - -func text(value string, max int, allowEmpty bool) bool { - if (!allowEmpty && value == "") || len(value) > max || !utf8.ValidString(value) { - return false - } - for _, character := range value { - if character < 0x20 || character == 0x7f { - return false - } - } - return true -} diff --git a/internal/managedsdk/actionregistry/registry.go b/internal/managedsdk/actionregistry/registry.go deleted file mode 100644 index 28a29cdc..00000000 --- a/internal/managedsdk/actionregistry/registry.go +++ /dev/null @@ -1,376 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Package actionregistry defines the canonical action vocabulary shared by -// management policy, node capability reports, and real side-effect adapters. -// -// Importing this package does not enable governance. The zero-value Profile is -// explicitly unmanaged, so existing nodes and protocols retain their current -// behavior until an operator selects actions and a non-off mode. -package actionregistry - -import ( - "fmt" - "regexp" - "sort" - "strings" -) - -const SchemaVersion uint16 = 1 - -type ControlMode string - -const ( - ModeOff ControlMode = "off" - ModeObserve ControlMode = "observe" - ModeLocalEnforce ControlMode = "local_enforce" - ModeManagedEnforce ControlMode = "managed_enforce" -) - -func (mode ControlMode) Normalize() ControlMode { - if mode == "" { - return ModeOff - } - return mode -} - -func (mode ControlMode) Validate() error { - switch mode.Normalize() { - case ModeOff, ModeObserve, ModeLocalEnforce, ModeManagedEnforce: - return nil - default: - return fmt.Errorf("actionregistry: unsupported control mode %q", mode) - } -} - -func (mode ControlMode) Enforces() bool { - mode = mode.Normalize() - return mode == ModeLocalEnforce || mode == ModeManagedEnforce -} - -func (mode ControlMode) Managed() bool { return mode.Normalize() == ModeManagedEnforce } - -type PayloadBinding string - -const ( - PayloadHash PayloadBinding = "sha256" - PayloadCanonicalHash PayloadBinding = "canonical_sha256" -) - -type PrivacyClass string - -const ( - PrivacyMetadataOnly PrivacyClass = "metadata_only" - PrivacyTypedMetadata PrivacyClass = "typed_metadata" - PrivacyLocalOnly PrivacyClass = "local_only" - PrivacyPayloadOptIn PrivacyClass = "payload_opt_in" - PrivacyFederatedContent PrivacyClass = "federated_content" -) - -// Definition is the product contract for one canonical action. An action is -// not considered enforced merely because it appears here: a node must also -// report a matching, enforceable adapter capability. -type Definition struct { - Version uint16 `json:"version"` - Name string `json:"name"` - Aliases []string `json:"aliases,omitempty"` - ResourceKind string `json:"resource_kind"` - PayloadBinding PayloadBinding `json:"payload_binding"` - Privacy PrivacyClass `json:"privacy"` - Suspendable bool `json:"suspendable"` - Resumable bool `json:"resumable"` - Constraints []string `json:"constraints,omitempty"` - Description string `json:"description"` -} - -var namePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$`) -var identifierPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,127}$`) - -func (definition Definition) Validate() error { - if definition.Version != SchemaVersion || !namePattern.MatchString(definition.Name) { - return fmt.Errorf("actionregistry: invalid action identity %q", definition.Name) - } - if !identifierPattern.MatchString(definition.ResourceKind) { - return fmt.Errorf("actionregistry: invalid resource kind for %q", definition.Name) - } - switch definition.PayloadBinding { - case PayloadHash, PayloadCanonicalHash: - default: - return fmt.Errorf("actionregistry: invalid payload binding for %q", definition.Name) - } - switch definition.Privacy { - case PrivacyMetadataOnly, PrivacyTypedMetadata, PrivacyLocalOnly, PrivacyPayloadOptIn, PrivacyFederatedContent: - default: - return fmt.Errorf("actionregistry: invalid privacy class for %q", definition.Name) - } - if definition.Resumable && !definition.Suspendable { - return fmt.Errorf("actionregistry: resumable action %q must be suspendable", definition.Name) - } - if strings.TrimSpace(definition.Description) == "" || len(definition.Description) > 256 { - return fmt.Errorf("actionregistry: invalid description for %q", definition.Name) - } - seen := map[string]struct{}{definition.Name: {}} - for _, alias := range definition.Aliases { - if !namePattern.MatchString(alias) { - return fmt.Errorf("actionregistry: invalid alias %q", alias) - } - if _, duplicate := seen[alias]; duplicate { - return fmt.Errorf("actionregistry: duplicate alias %q", alias) - } - seen[alias] = struct{}{} - } - seen = make(map[string]struct{}, len(definition.Constraints)) - for _, constraint := range definition.Constraints { - if !identifierPattern.MatchString(constraint) { - return fmt.Errorf("actionregistry: invalid constraint %q", constraint) - } - if _, duplicate := seen[constraint]; duplicate { - return fmt.Errorf("actionregistry: duplicate constraint %q", constraint) - } - seen[constraint] = struct{}{} - } - return nil -} - -type Registry struct { - byName map[string]Definition - canonical []Definition -} - -func New(definitions []Definition) (*Registry, error) { - registry := &Registry{byName: make(map[string]Definition)} - for _, definition := range definitions { - definition.Aliases = append([]string(nil), definition.Aliases...) - definition.Constraints = append([]string(nil), definition.Constraints...) - if err := definition.Validate(); err != nil { - return nil, err - } - for _, name := range append([]string{definition.Name}, definition.Aliases...) { - if existing, duplicate := registry.byName[name]; duplicate { - return nil, fmt.Errorf("actionregistry: name %q belongs to both %q and %q", name, existing.Name, definition.Name) - } - registry.byName[name] = definition - } - registry.canonical = append(registry.canonical, definition) - } - sort.Slice(registry.canonical, func(i, j int) bool { return registry.canonical[i].Name < registry.canonical[j].Name }) - return registry, nil -} - -func (registry *Registry) Resolve(name string) (Definition, bool) { - if registry == nil { - return Definition{}, false - } - definition, found := registry.byName[strings.TrimSpace(name)] - return definition, found -} - -func (registry *Registry) CanonicalName(name string) (string, bool) { - definition, found := registry.Resolve(name) - return definition.Name, found -} - -func (registry *Registry) Definitions() []Definition { - if registry == nil { - return nil - } - out := make([]Definition, len(registry.canonical)) - copy(out, registry.canonical) - for index := range out { - out[index].Aliases = append([]string(nil), out[index].Aliases...) - out[index].Constraints = append([]string(nil), out[index].Constraints...) - } - return out -} - -// Profile is an explicitly selected node policy posture. Empty mode is a -// compatibility alias for off. Every non-off profile must name actions (or -// the explicit "*" selector), preventing an upgrade from silently governing -// newly added actions. -type Profile struct { - Version uint16 `json:"version,omitempty"` - Mode ControlMode `json:"mode,omitempty"` - Actions []string `json:"actions,omitempty"` - StrictReceive []string `json:"strict_receive,omitempty"` -} - -func (profile Profile) Validate(registry *Registry) error { - if profile.Version != 0 && profile.Version != SchemaVersion { - return fmt.Errorf("actionregistry: unsupported profile version %d", profile.Version) - } - if err := profile.Mode.Validate(); err != nil { - return err - } - mode := profile.Mode.Normalize() - if mode == ModeOff { - if len(profile.Actions) > 0 || len(profile.StrictReceive) > 0 { - return fmt.Errorf("actionregistry: off profile cannot select actions") - } - return nil - } - if registry == nil { - return fmt.Errorf("actionregistry: registry is required for an enabled profile") - } - actions, all, err := normalizeSelection(registry, profile.Actions) - if err != nil { - return err - } - if len(actions) == 0 && !all { - return fmt.Errorf("actionregistry: enabled profile requires explicit actions or *") - } - if len(profile.StrictReceive) > 0 && !mode.Enforces() { - return fmt.Errorf("actionregistry: strict receive requires an enforcement mode") - } - strict, strictAll, err := normalizeSelection(registry, profile.StrictReceive) - if err != nil { - return fmt.Errorf("actionregistry: strict receive: %w", err) - } - if strictAll && !all { - return fmt.Errorf("actionregistry: strict receive * requires governed actions *") - } - if !all { - for action := range strict { - if _, governed := actions[action]; !governed { - return fmt.Errorf("actionregistry: strict receive action %q is not governed", action) - } - } - } - return nil -} - -func (profile Profile) AppliesTo(registry *Registry, action string) bool { - if profile.Mode.Normalize() == ModeOff || registry == nil { - return false - } - canonical, found := registry.CanonicalName(action) - if !found { - return false - } - selection, all, err := normalizeSelection(registry, profile.Actions) - if err != nil { - return false - } - _, selected := selection[canonical] - return all || selected -} - -func (profile Profile) Enforces(registry *Registry, action string) bool { - return profile.Mode.Enforces() && profile.AppliesTo(registry, action) -} - -func (profile Profile) Observes(registry *Registry, action string) bool { - return profile.Mode.Normalize() == ModeObserve && profile.AppliesTo(registry, action) -} - -func (profile Profile) RequiresGovernedReceive(registry *Registry, action string) bool { - if !profile.Mode.Enforces() || registry == nil { - return false - } - canonical, found := registry.CanonicalName(action) - if !found { - return false - } - selection, all, err := normalizeSelection(registry, profile.StrictReceive) - if err != nil { - return false - } - _, selected := selection[canonical] - return all || selected -} - -func normalizeSelection(registry *Registry, values []string) (map[string]struct{}, bool, error) { - selection := make(map[string]struct{}, len(values)) - all := false - for _, raw := range values { - name := strings.TrimSpace(raw) - if name == "*" { - if all || len(values) != 1 { - return nil, false, fmt.Errorf("actionregistry: * must be the only selector") - } - all = true - continue - } - canonical, found := registry.CanonicalName(name) - if !found { - return nil, false, fmt.Errorf("actionregistry: unknown action %q", name) - } - if _, duplicate := selection[canonical]; duplicate { - return nil, false, fmt.Errorf("actionregistry: duplicate action %q", canonical) - } - selection[canonical] = struct{}{} - } - return selection, all, nil -} - -// AdapterCapability is a node's signed/reportable assertion that a concrete -// adapter can observe or enforce an action. Management must not infer this -// from the registry alone. -type AdapterCapability struct { - Action string `json:"action"` - AdapterID string `json:"adapter_id"` - AdapterVersion string `json:"adapter_version"` - Observe bool `json:"observe"` - Enforce bool `json:"enforce"` - Suspend bool `json:"suspend"` - Resume bool `json:"resume"` - Receipt bool `json:"receipt"` -} - -// Validate checks a concrete adapter claim against the canonical registry. -// It is exported because signed fleet capability reports are validated in the -// authority package rather than trusting browser or adapter-supplied labels. -func (capability AdapterCapability) Validate(registry *Registry) error { - definition, found := registry.Resolve(capability.Action) - if !found { - return fmt.Errorf("actionregistry: capability references unknown action %q", capability.Action) - } - if !identifierPattern.MatchString(capability.AdapterID) || strings.TrimSpace(capability.AdapterVersion) == "" || len(capability.AdapterVersion) > 64 { - return fmt.Errorf("actionregistry: invalid adapter identity for %q", definition.Name) - } - if capability.Enforce && !capability.Observe { - return fmt.Errorf("actionregistry: enforcing adapter %q must also observe", capability.AdapterID) - } - if capability.Suspend && (!capability.Enforce || !definition.Suspendable) { - return fmt.Errorf("actionregistry: adapter %q cannot suspend %q", capability.AdapterID, definition.Name) - } - if capability.Resume && (!capability.Suspend || !definition.Resumable) { - return fmt.Errorf("actionregistry: adapter %q cannot resume %q", capability.AdapterID, definition.Name) - } - return nil -} - -func Builtins() *Registry { - registry, err := New(builtinDefinitions()) - if err != nil { - panic(err) - } - return registry -} - -func builtinDefinitions() []Definition { - definition := func(name, resource string, privacy PrivacyClass, suspendable, resumable bool, description string, aliases ...string) Definition { - return Definition{Version: SchemaVersion, Name: name, Aliases: aliases, ResourceKind: resource, PayloadBinding: PayloadCanonicalHash, Privacy: privacy, Suspendable: suspendable, Resumable: resumable, Description: description} - } - return []Definition{ - definition("browser.navigate", "url", PrivacyMetadataOnly, true, true, "Navigate a controlled browser to a URL."), - definition("data.export", "destination", PrivacyFederatedContent, true, true, "Export bounded data to an external destination."), - definition("data.read", "message", PrivacyMetadataOnly, false, false, "Read a received message or bounded data object.", "message.read"), - definition("data.send.binary", "agent", PrivacyFederatedContent, true, true, "Send binary data to a Pilot peer."), - definition("data.send.json", "agent", PrivacyFederatedContent, true, true, "Send JSON data to a Pilot peer."), - definition("data.send.text", "agent", PrivacyFederatedContent, true, true, "Send a text message to a Pilot peer.", "message.send"), - definition("event.publish", "topic", PrivacyFederatedContent, true, true, "Publish an event to a Pilot topic."), - definition("file.delete", "file", PrivacyLocalOnly, false, false, "Delete a local or managed file."), - definition("file.read", "file", PrivacyLocalOnly, false, false, "Read a local or managed file."), - definition("file.share", "agent", PrivacyFederatedContent, true, true, "Share a file with a Pilot peer."), - definition("file.write", "file", PrivacyLocalOnly, true, true, "Write a local or managed file."), - definition("http.request", "url", PrivacyFederatedContent, true, true, "Issue an outbound HTTP request."), - definition("process.execute", "executable", PrivacyLocalOnly, true, false, "Start a local process."), - definition("tool.invoke", "tool", PrivacyFederatedContent, true, true, "Invoke a registered agent tool."), - definition("trust.accept", "agent", PrivacyMetadataOnly, true, true, "Accept an inbound trust relationship."), - definition("trust.auto_accept", "agent", PrivacyMetadataOnly, false, false, "Automatically accept an inbound trust relationship."), - definition("trust.reject", "agent", PrivacyMetadataOnly, false, false, "Reject an inbound trust request."), - definition("trust.request", "agent", PrivacyMetadataOnly, true, true, "Request a trust relationship with another agent."), - definition("trust.revoke", "agent", PrivacyMetadataOnly, false, false, "Revoke an existing trust relationship."), - definition("wallet.pay", "counterparty", PrivacyFederatedContent, true, true, "Transfer value to a counterparty."), - definition("webhook.send", "url", PrivacyFederatedContent, true, true, "Deliver an outbound webhook."), - } -} diff --git a/internal/managedsdk/authority/bundle.go b/internal/managedsdk/authority/bundle.go deleted file mode 100644 index d3c30fe6..00000000 --- a/internal/managedsdk/authority/bundle.go +++ /dev/null @@ -1,298 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Package authority defines tenant-root-signed key distribution state used by -// local Pilot enforcement points. -package authority - -import ( - "bytes" - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/binary" - "encoding/hex" - "fmt" - "sort" - "strings" - "time" - "unicode/utf8" -) - -const ( - SchemaVersion uint16 = 1 - TrustDomain = "pilot-trust-bundle-v1" - MaxBundleTTL = 7 * 24 * time.Hour - MaxBundleKeys = 256 - MaxBundleClockSkew = time.Minute -) - -type KeyUsage string - -const ( - UsageIntent KeyUsage = "intent" - UsageDecision KeyUsage = "decision" - UsagePolicy KeyUsage = "policy" - UsageMandate KeyUsage = "mandate" - UsageApproval KeyUsage = "approval" - UsageReceipt KeyUsage = "receipt" -) - -// AuthorityKey is an online key delegated by the tenant root. Intent and -// receipt keys are scoped to one agent; authority-wide issuer keys leave -// AgentID empty. -type AuthorityKey struct { - KeyID string `json:"key_id"` - AgentID string `json:"agent_id,omitempty"` - PublicKey string `json:"public_key"` - Usages []KeyUsage `json:"usages"` - NotBefore int64 `json:"not_before"` - ExpiresAt int64 `json:"expires_at"` -} - -// TrustBundle is the complete signed online-key view for one tenant. Removing -// or narrowing a previously active key requires a higher RevocationEpoch. -type TrustBundle struct { - Version uint16 `json:"version"` - TenantID string `json:"tenant_id"` - Revision uint64 `json:"revision"` - PolicyRevision uint64 `json:"policy_revision"` - RevocationEpoch uint64 `json:"revocation_epoch"` - IssuedAt int64 `json:"issued_at"` - ExpiresAt int64 `json:"expires_at"` - RootKeyID string `json:"root_key_id"` - Keys []AuthorityKey `json:"keys"` - Signature string `json:"signature"` -} - -func (bundle TrustBundle) Validate() error { - if bundle.Version != SchemaVersion { - return fmt.Errorf("authority: trust bundle version %d is unsupported", bundle.Version) - } - if err := validateIdentifier("tenant_id", bundle.TenantID); err != nil { - return err - } - if err := validateIdentifier("root_key_id", bundle.RootKeyID); err != nil { - return err - } - if bundle.Revision == 0 { - return fmt.Errorf("authority: trust bundle revision must be positive") - } - if bundle.IssuedAt <= 0 || bundle.ExpiresAt <= bundle.IssuedAt { - return fmt.Errorf("authority: invalid trust bundle validity window") - } - if bundle.ExpiresAt-bundle.IssuedAt > int64(MaxBundleTTL/time.Second) { - return fmt.Errorf("authority: trust bundle validity exceeds %s", MaxBundleTTL) - } - if len(bundle.Keys) > MaxBundleKeys { - return fmt.Errorf("authority: trust bundle exceeds %d keys", MaxBundleKeys) - } - seen := make(map[string]struct{}, len(bundle.Keys)) - for _, key := range bundle.Keys { - if err := key.validate(bundle.IssuedAt, bundle.ExpiresAt); err != nil { - return err - } - if _, exists := seen[key.KeyID]; exists { - return fmt.Errorf("authority: duplicate key_id %q", key.KeyID) - } - seen[key.KeyID] = struct{}{} - } - return nil -} - -func (key AuthorityKey) validate(bundleIssuedAt, bundleExpiresAt int64) error { - if err := validateIdentifier("key_id", key.KeyID); err != nil { - return err - } - if key.AgentID != "" { - if err := validateIdentifier("agent_id", key.AgentID); err != nil { - return err - } - } - decoded, err := base64.StdEncoding.DecodeString(key.PublicKey) - if err != nil || len(decoded) != ed25519.PublicKeySize { - return fmt.Errorf("authority: key %q has invalid Ed25519 public_key", key.KeyID) - } - if base64.StdEncoding.EncodeToString(decoded) != key.PublicKey { - return fmt.Errorf("authority: key %q public_key is not canonically encoded", key.KeyID) - } - if key.NotBefore <= 0 || key.ExpiresAt > bundleExpiresAt || key.ExpiresAt <= key.NotBefore || key.ExpiresAt <= bundleIssuedAt { - return fmt.Errorf("authority: key %q validity exceeds its trust bundle", key.KeyID) - } - if len(key.Usages) == 0 || len(key.Usages) > 6 { - return fmt.Errorf("authority: key %q must have 1-6 usages", key.KeyID) - } - seen := make(map[KeyUsage]struct{}, len(key.Usages)) - for _, usage := range key.Usages { - switch usage { - case UsageIntent, UsageDecision, UsagePolicy, UsageMandate, UsageApproval, UsageReceipt: - default: - return fmt.Errorf("authority: key %q has unknown usage %q", key.KeyID, usage) - } - if _, exists := seen[usage]; exists { - return fmt.Errorf("authority: key %q repeats usage %q", key.KeyID, usage) - } - seen[usage] = struct{}{} - } - if _, hasIntent := seen[UsageIntent]; hasIntent && key.AgentID == "" { - return fmt.Errorf("authority: intent key %q must be scoped to an agent", key.KeyID) - } - if _, hasReceipt := seen[UsageReceipt]; hasReceipt && key.AgentID == "" { - return fmt.Errorf("authority: receipt key %q must be scoped to an agent", key.KeyID) - } - if key.AgentID != "" { - for usage := range seen { - if usage != UsageIntent && usage != UsageReceipt { - return fmt.Errorf("authority: tenant issuer key %q cannot be agent-scoped", key.KeyID) - } - } - } - return nil -} - -func (bundle TrustBundle) Canonical() ([]byte, error) { - if err := bundle.Validate(); err != nil { - return nil, err - } - keys := append([]AuthorityKey(nil), bundle.Keys...) - sort.Slice(keys, func(i, j int) bool { return keys[i].KeyID < keys[j].KeyID }) - writer := canonicalWriter{} - writer.string(TrustDomain) - writer.u16(bundle.Version) - writer.string(bundle.TenantID) - writer.u64(bundle.Revision) - writer.u64(bundle.PolicyRevision) - writer.u64(bundle.RevocationEpoch) - writer.i64(bundle.IssuedAt) - writer.i64(bundle.ExpiresAt) - writer.string(bundle.RootKeyID) - // #nosec G115 -- Validate limits keys to MaxBundleKeys, which is below uint16 capacity. - writer.u16(uint16(len(keys))) - for _, key := range keys { - writer.string(key.KeyID) - writer.string(key.AgentID) - writer.string(key.PublicKey) - usages := append([]KeyUsage(nil), key.Usages...) - sort.Slice(usages, func(i, j int) bool { return usages[i] < usages[j] }) - // #nosec G115 -- AuthorityKey.Validate bounds usages below uint16 capacity. - writer.u16(uint16(len(usages))) - for _, usage := range usages { - writer.string(string(usage)) - } - writer.i64(key.NotBefore) - writer.i64(key.ExpiresAt) - } - if writer.err != nil { - return nil, writer.err - } - return writer.Buffer.Bytes(), nil -} - -func (bundle TrustBundle) Hash() (string, error) { - canonical, err := bundle.Canonical() - if err != nil { - return "", err - } - sum := sha256.Sum256(canonical) - return hex.EncodeToString(sum[:]), nil -} - -func (bundle *TrustBundle) Sign(rootPrivateKey ed25519.PrivateKey) error { - if len(rootPrivateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authority: invalid tenant root private key") - } - canonical, err := bundle.Canonical() - if err != nil { - return err - } - bundle.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(rootPrivateKey, canonical)) - return nil -} - -func (bundle TrustBundle) Verify(rootPublicKey ed25519.PublicKey, now time.Time) error { - if err := bundle.VerifySignature(rootPublicKey); err != nil { - return err - } - nowUnix := now.Unix() - if bundle.IssuedAt > nowUnix+int64(MaxBundleClockSkew/time.Second) { - return fmt.Errorf("authority: trust bundle is not yet valid") - } - if bundle.ExpiresAt < nowUnix { - return fmt.Errorf("authority: trust bundle is expired") - } - return nil -} - -func (bundle TrustBundle) VerifySignature(rootPublicKey ed25519.PublicKey) error { - canonical, err := bundle.Canonical() - if err != nil { - return err - } - if len(rootPublicKey) != ed25519.PublicKeySize { - return fmt.Errorf("authority: invalid pinned tenant root public key") - } - signature, err := base64.StdEncoding.DecodeString(bundle.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(rootPublicKey, canonical, signature) { - return fmt.Errorf("authority: invalid trust bundle signature") - } - return nil -} - -func (key AuthorityKey) publicKey() ed25519.PublicKey { - decoded, _ := base64.StdEncoding.DecodeString(key.PublicKey) - return append(ed25519.PublicKey(nil), decoded...) -} - -func (key AuthorityKey) permits(usage KeyUsage) bool { - for _, candidate := range key.Usages { - if candidate == usage { - return true - } - } - return false -} - -func validateIdentifier(name, value string) error { - if value == "" || len(value) > 128 || !utf8.ValidString(value) { - return fmt.Errorf("authority: invalid %s", name) - } - for _, character := range value { - if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || - (character >= '0' && character <= '9') || strings.ContainsRune("._:/@-", character) { - continue - } - return fmt.Errorf("authority: invalid %s", name) - } - return nil -} - -type canonicalWriter struct { - bytes.Buffer - err error -} - -func (writer *canonicalWriter) u16(value uint16) { writer.write(value) } -func (writer *canonicalWriter) u64(value uint64) { writer.write(value) } -func (writer *canonicalWriter) i64(value int64) { writer.write(value) } -func (writer *canonicalWriter) boolean(value bool) { - if value { - writer.u16(1) - return - } - writer.u16(0) -} -func (writer *canonicalWriter) write(value any) { - if writer.err == nil { - writer.err = binary.Write(&writer.Buffer, binary.BigEndian, value) - } -} -func (writer *canonicalWriter) string(value string) { - if len(value) > int(^uint32(0)) { - writer.err = fmt.Errorf("authority: canonical string is too large") - return - } - // #nosec G115 -- the immediately preceding guard rejects lengths above uint32 capacity. - writer.write(uint32(len(value))) - if writer.err == nil { - _, writer.err = writer.WriteString(value) - } -} diff --git a/internal/managedsdk/authority/client_support.go b/internal/managedsdk/authority/client_support.go deleted file mode 100644 index 687780e1..00000000 --- a/internal/managedsdk/authority/client_support.go +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package authority - -import ( - "context" - "strings" - "unicode/utf8" -) - -// StoredRollout and RolloutPersistence are the narrow persistence contract -// accepted by PolicyManager. The managed node normally uses the in-memory -// implementation; hosted persistence remains in the private control plane. -type StoredRollout struct { - Publication PolicyPublication - Bundle PolicyBundle - Acknowledgements []PolicyAcknowledgement - Activation *PolicyActivation - Withdrawal *PolicyWithdrawal -} - -type RolloutPersistence interface { - SavePolicyPublication(context.Context, PolicyPublication, PolicyBundle) error - AppendPolicyAcknowledgement(context.Context, PolicyAcknowledgement) error - SavePolicyActivation(context.Context, PolicyActivation) error - LoadRollouts(context.Context) ([]StoredRollout, error) -} - -type TenantRolloutPersistence interface { - RolloutPersistence - LoadTenantRollouts(context.Context, string) ([]StoredRollout, error) -} - -type RolloutGenerationPersistence interface { - TenantRolloutPersistence - RolloutGeneration(context.Context, string) (uint64, error) -} - -type RolloutWithdrawalPersistence interface { - SavePolicyWithdrawal(context.Context, PolicyWithdrawal) error -} - -func boundedAuditText(value string, maximum int, allowEmpty bool) bool { - if (!allowEmpty && strings.TrimSpace(value) == "") || len(value) > maximum || !utf8.ValidString(value) { - return false - } - for _, character := range value { - if character < 0x20 || character == 0x7f { - return false - } - } - return true -} - -func lowerHexIdentifier(value string, length int) bool { - if len(value) != length { - return false - } - for _, character := range value { - if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f')) { - return false - } - } - return true -} diff --git a/internal/managedsdk/authority/errors.go b/internal/managedsdk/authority/errors.go deleted file mode 100644 index 8024fa44..00000000 --- a/internal/managedsdk/authority/errors.go +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package authority - -import ( - "errors" - "fmt" -) - -var ( - ErrPersistence = errors.New("authority: durable state unavailable") - ErrStateConflict = errors.New("authority: conflicting durable state") - ErrUnknownPolicyPublication = errors.New("authority: unknown policy publication") -) - -func persistenceError(operation string, err error) error { - if errors.Is(err, ErrStateConflict) { - return fmt.Errorf("authority: %s: %w", operation, err) - } - return fmt.Errorf("%w: %s: %v", ErrPersistence, operation, err) -} diff --git a/internal/managedsdk/authority/fleet.go b/internal/managedsdk/authority/fleet.go deleted file mode 100644 index abcb9cbe..00000000 --- a/internal/managedsdk/authority/fleet.go +++ /dev/null @@ -1,423 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package authority - -import ( - "crypto/ed25519" - "encoding/base64" - "fmt" - "sort" - "strings" - "time" - - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/actionregistry" -) - -const ( - FleetCommandVersion uint16 = 1 // accepted legacy wire format - FleetCommandVersionV2 uint16 = 2 - FleetReportVersion uint16 = 1 - FleetReportVersionV2 uint16 = 2 - FleetCommandDomain = "pilot-fleet-command-v1" - FleetCommandDomainV2 = "pilot-fleet-command-v2" - FleetReportDomain = "pilot-fleet-report-v1" - FleetReportDomainV2 = "pilot-fleet-report-v2" - FleetResultDomain = "pilot-fleet-result-v1" - MaxFleetCommandTTL = 24 * time.Hour - MaxFleetTargets = 10_000 -) - -// FleetCommandKind is deliberately a small, non-shell command vocabulary. A -// node executes a command only after checking this separately signed object -// against its root-pinned authority trust. -type FleetCommandKind string - -const ( - FleetCommandRefreshPolicy FleetCommandKind = "refresh_policy" - FleetCommandExportReceipts FleetCommandKind = "export_receipts" - FleetCommandReloadControl FleetCommandKind = "reload_control" - FleetCommandSyncState FleetCommandKind = "sync_state" - FleetCommandDiagnostics FleetCommandKind = "collect_diagnostics" - FleetCommandRestartRuntime FleetCommandKind = "restart_runtime" - FleetCommandShutdownRuntime FleetCommandKind = "shutdown_runtime" -) - -// FleetCommand is a short-lived command addressed to one or more enrolled -// agent identities. The authority stores and distributes it but does not infer -// authority from transport or UI identity: the operator supplies a separately -// signed artifact. -type FleetCommand struct { - Version uint16 `json:"version"` - ID string `json:"id"` - TenantID string `json:"tenant_id"` - Targets []string `json:"targets"` - Kind FleetCommandKind `json:"kind"` - Reason string `json:"reason,omitempty"` - IssuedAt int64 `json:"issued_at"` - ExpiresAt int64 `json:"expires_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func (command FleetCommand) Validate() error { - if command.Version != FleetCommandVersion && command.Version != FleetCommandVersionV2 { - return fmt.Errorf("authority: unsupported fleet command version") - } - for name, value := range map[string]string{"id": command.ID, "tenant_id": command.TenantID, "key_id": command.KeyID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if len(command.Targets) == 0 || len(command.Targets) > MaxFleetTargets { - return fmt.Errorf("authority: fleet command requires 1-%d targets", MaxFleetTargets) - } - seen := make(map[string]struct{}, len(command.Targets)) - for _, target := range command.Targets { - if err := validateIdentifier("fleet target", target); err != nil { - return err - } - if _, exists := seen[target]; exists { - return fmt.Errorf("authority: duplicate fleet command target %q", target) - } - seen[target] = struct{}{} - } - switch command.Kind { - case FleetCommandRefreshPolicy, FleetCommandExportReceipts, FleetCommandReloadControl, FleetCommandSyncState, FleetCommandDiagnostics, FleetCommandRestartRuntime, FleetCommandShutdownRuntime: - default: - return fmt.Errorf("authority: unsupported fleet command kind %q", command.Kind) - } - if command.IssuedAt <= 0 || command.ExpiresAt <= command.IssuedAt || command.ExpiresAt-command.IssuedAt > int64(MaxFleetCommandTTL/time.Second) { - return fmt.Errorf("authority: invalid fleet command validity") - } - if command.Version == FleetCommandVersion && command.Reason != "" { - return fmt.Errorf("authority: legacy fleet command cannot carry a reason") - } - if command.Version == FleetCommandVersionV2 && (!boundedFleetText(command.Reason, 256, false) || len(strings.TrimSpace(command.Reason)) < 8) { - return fmt.Errorf("authority: fleet command v2 requires an auditable reason") - } - return nil -} - -func (command FleetCommand) Canonical() ([]byte, error) { - if err := command.Validate(); err != nil { - return nil, err - } - targets := append([]string(nil), command.Targets...) - sort.Strings(targets) - writer := canonicalWriter{} - domain := FleetCommandDomain - if command.Version == FleetCommandVersionV2 { - domain = FleetCommandDomainV2 - } - writer.string(domain) - writer.u16(command.Version) - writer.string(command.ID) - writer.string(command.TenantID) - // #nosec G115 -- Validate limits targets to MaxFleetTargets, which is below uint16 capacity. - writer.u16(uint16(len(targets))) - for _, target := range targets { - writer.string(target) - } - writer.string(string(command.Kind)) - if command.Version == FleetCommandVersionV2 { - writer.string(command.Reason) - } - writer.i64(command.IssuedAt) - writer.i64(command.ExpiresAt) - writer.string(command.KeyID) - if writer.err != nil { - return nil, writer.err - } - return writer.Buffer.Bytes(), nil -} - -func (command *FleetCommand) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authority: invalid fleet command signing key") - } - canonical, err := command.Canonical() - if err != nil { - return err - } - command.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (command FleetCommand) Verify(publicKey ed25519.PublicKey, now time.Time) error { - if err := command.Validate(); err != nil { - return err - } - if len(publicKey) != ed25519.PublicKeySize { - return fmt.Errorf("authority: invalid fleet command public key") - } - if now.Unix() < command.IssuedAt-int64(MaxBundleClockSkew/time.Second) || now.Unix() > command.ExpiresAt+int64(MaxBundleClockSkew/time.Second) { - return fmt.Errorf("authority: fleet command is outside its validity window") - } - canonical, err := command.Canonical() - if err != nil { - return err - } - signature, err := base64.StdEncoding.DecodeString(command.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("authority: invalid fleet command signature") - } - return nil -} - -func (command FleetCommand) TargetsAgent(agentID string) bool { - for _, target := range command.Targets { - if target == agentID { - return true - } - } - return false -} - -// FleetNodeReport contains the bounded, non-sensitive liveness information a -// daemon elects to make visible to its tenant operators. It never contains -// prompts, payloads, peer addresses, local paths, or environment values. -type FleetNodeReport struct { - Version uint16 `json:"version"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - NodeID uint32 `json:"node_id"` - AgentVersion string `json:"agent_version,omitempty"` - HarnessID string `json:"harness_id,omitempty"` - HarnessVersion string `json:"harness_version,omitempty"` - ConnectorVersion string `json:"connector_version,omitempty"` - Capabilities []actionregistry.AdapterCapability `json:"capabilities,omitempty"` - ObservedAt int64 `json:"observed_at"` - UptimeSeconds uint64 `json:"uptime_seconds"` - Connections uint32 `json:"connections"` - Peers uint32 `json:"peers"` - EncryptedPeers uint32 `json:"encrypted_peers"` - BytesSent uint64 `json:"bytes_sent"` - BytesReceived uint64 `json:"bytes_received"` - PolicyRevision uint64 `json:"policy_revision"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func (report FleetNodeReport) Validate() error { - if report.Version != FleetReportVersion && report.Version != FleetReportVersionV2 { - return fmt.Errorf("authority: unsupported fleet report version") - } - for name, value := range map[string]string{"tenant_id": report.TenantID, "agent_id": report.AgentID, "key_id": report.KeyID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if report.ObservedAt <= 0 || len(report.AgentVersion) > 128 || strings.ContainsAny(report.AgentVersion, "\r\n\x00") { - return fmt.Errorf("authority: invalid fleet node report") - } - if report.Version == FleetReportVersion { - if report.HarnessID != "" || report.HarnessVersion != "" || report.ConnectorVersion != "" || len(report.Capabilities) != 0 { - return fmt.Errorf("authority: legacy fleet report cannot carry capability data") - } - return nil - } - for name, value := range map[string]string{"harness_id": report.HarnessID, "connector_version": report.ConnectorVersion} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if report.HarnessVersion == "" || len(report.HarnessVersion) > 128 || strings.ContainsAny(report.HarnessVersion, "\r\n\x00") { - return fmt.Errorf("authority: invalid harness version") - } - if len(report.Capabilities) == 0 || len(report.Capabilities) > len(actionregistry.Builtins().Definitions()) { - return fmt.Errorf("authority: capability-aware report requires a bounded capability set") - } - registry := actionregistry.Builtins() - seen := make(map[string]struct{}, len(report.Capabilities)) - for _, capability := range report.Capabilities { - if err := capability.Validate(registry); err != nil { - return fmt.Errorf("authority: invalid adapter capability: %w", err) - } - canonical, _ := registry.CanonicalName(capability.Action) - if _, duplicate := seen[canonical]; duplicate { - return fmt.Errorf("authority: duplicate adapter capability for %q", canonical) - } - seen[canonical] = struct{}{} - } - return nil -} - -func (report FleetNodeReport) Canonical() ([]byte, error) { - if err := report.Validate(); err != nil { - return nil, err - } - writer := canonicalWriter{} - domain := FleetReportDomain - if report.Version == FleetReportVersionV2 { - domain = FleetReportDomainV2 - } - writer.string(domain) - writer.u16(report.Version) - writer.string(report.TenantID) - writer.string(report.AgentID) - writer.u64(uint64(report.NodeID)) - writer.string(report.AgentVersion) - writer.i64(report.ObservedAt) - writer.u64(report.UptimeSeconds) - writer.u64(uint64(report.Connections)) - writer.u64(uint64(report.Peers)) - writer.u64(uint64(report.EncryptedPeers)) - writer.u64(report.BytesSent) - writer.u64(report.BytesReceived) - writer.u64(report.PolicyRevision) - if report.Version == FleetReportVersionV2 { - writer.string(report.HarnessID) - writer.string(report.HarnessVersion) - writer.string(report.ConnectorVersion) - capabilities := append([]actionregistry.AdapterCapability(nil), report.Capabilities...) - registry := actionregistry.Builtins() - sort.Slice(capabilities, func(i, j int) bool { - left, _ := registry.CanonicalName(capabilities[i].Action) - right, _ := registry.CanonicalName(capabilities[j].Action) - return left < right - }) - // #nosec G115 -- report validation bounds capabilities below uint16 capacity. - writer.u16(uint16(len(capabilities))) - for _, capability := range capabilities { - canonical, _ := registry.CanonicalName(capability.Action) - writer.string(canonical) - writer.string(capability.AdapterID) - writer.string(capability.AdapterVersion) - writer.boolean(capability.Observe) - writer.boolean(capability.Enforce) - writer.boolean(capability.Suspend) - writer.boolean(capability.Resume) - writer.boolean(capability.Receipt) - } - } - writer.string(report.KeyID) - if writer.err != nil { - return nil, writer.err - } - return writer.Buffer.Bytes(), nil -} - -func (report *FleetNodeReport) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authority: invalid fleet report signing key") - } - canonical, err := report.Canonical() - if err != nil { - return err - } - report.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (report FleetNodeReport) Verify(publicKey ed25519.PublicKey, now time.Time) error { - if err := report.Validate(); err != nil { - return err - } - if len(publicKey) != ed25519.PublicKeySize { - return fmt.Errorf("authority: invalid fleet report public key") - } - if report.ObservedAt > now.Unix()+int64(MaxBundleClockSkew/time.Second) || report.ObservedAt < now.Add(-24*time.Hour).Unix() { - return fmt.Errorf("authority: fleet report is outside its accepted observation window") - } - canonical, err := report.Canonical() - if err != nil { - return err - } - signature, err := base64.StdEncoding.DecodeString(report.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("authority: invalid fleet report signature") - } - return nil -} - -// FleetCommandResult records the bounded result of one allowlisted command. -// DetailCode is intentionally an identifier rather than a remote error string. -type FleetCommandResult struct { - Version uint16 `json:"version"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - CommandID string `json:"command_id"` - Outcome string `json:"outcome"` - DetailCode string `json:"detail_code,omitempty"` - ObservedAt int64 `json:"observed_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func (result FleetCommandResult) Validate() error { - if result.Version != FleetReportVersion { - return fmt.Errorf("authority: unsupported fleet command result version") - } - for name, value := range map[string]string{"tenant_id": result.TenantID, "agent_id": result.AgentID, "command_id": result.CommandID, "key_id": result.KeyID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if result.DetailCode != "" { - if err := validateIdentifier("detail_code", result.DetailCode); err != nil { - return err - } - } - switch result.Outcome { - case "succeeded", "failed", "rejected": - default: - return fmt.Errorf("authority: invalid fleet command result outcome") - } - if result.ObservedAt <= 0 { - return fmt.Errorf("authority: invalid fleet command result observation time") - } - return nil -} - -func (result FleetCommandResult) Canonical() ([]byte, error) { - if err := result.Validate(); err != nil { - return nil, err - } - writer := canonicalWriter{} - writer.string(FleetResultDomain) - writer.u16(result.Version) - writer.string(result.TenantID) - writer.string(result.AgentID) - writer.string(result.CommandID) - writer.string(result.Outcome) - writer.string(result.DetailCode) - writer.i64(result.ObservedAt) - writer.string(result.KeyID) - if writer.err != nil { - return nil, writer.err - } - return writer.Buffer.Bytes(), nil -} - -func (result *FleetCommandResult) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authority: invalid fleet result signing key") - } - canonical, err := result.Canonical() - if err != nil { - return err - } - result.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (result FleetCommandResult) Verify(publicKey ed25519.PublicKey, now time.Time) error { - if err := result.Validate(); err != nil { - return err - } - if len(publicKey) != ed25519.PublicKeySize { - return fmt.Errorf("authority: invalid fleet result public key") - } - if result.ObservedAt > now.Unix()+int64(MaxBundleClockSkew/time.Second) || result.ObservedAt < now.Add(-24*time.Hour).Unix() { - return fmt.Errorf("authority: fleet result is outside its accepted observation window") - } - canonical, err := result.Canonical() - if err != nil { - return err - } - signature, err := base64.StdEncoding.DecodeString(result.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("authority: invalid fleet result signature") - } - return nil -} diff --git a/internal/managedsdk/authority/fleet_apps.go b/internal/managedsdk/authority/fleet_apps.go deleted file mode 100644 index 56676a54..00000000 --- a/internal/managedsdk/authority/fleet_apps.go +++ /dev/null @@ -1,264 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package authority - -import ( - "fmt" - "regexp" - "sort" - "strings" - "unicode/utf8" -) - -const ( - FleetAppsVersion uint16 = 1 - - // FleetAppsDocumentPath is the desired-app-set document's path relative to - // the node's fleet state root. It is delivered by an ordinary signed - // FleetStateMutation: apps deliberately introduce no new wire protocol and - // no new command vocabulary, so a node that already accepts state - // mutations needs no protocol upgrade to accept apps. - FleetAppsDocumentPath = "apps.json" - - MaxFleetAppsEntries = 64 - MaxFleetAppGrants = 64 - MaxFleetAppReasonSize = 256 -) - -// fleetAppIDPattern mirrors app-store/pkg/manifest idPattern. The authority -// refuses to distribute an identifier the node's manifest validator would -// later reject, so an operator learns at approval time rather than at the -// node's next reconcile. -var fleetAppIDPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9_-]*[a-z0-9])?)+$`) - -// fleetAppVersionPattern mirrors the manifest's simplified semver. -var fleetAppVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$`) - -// FleetAppGrant is one manifest-declared capability that a tenant -// administrator accepted on the fleet's behalf. It carries no authority of its -// own: the node re-reads the installed manifest and refuses to start an app -// whose declared grants are not covered by this accepted set, so a catalogue -// that later widens an app's grants fails closed instead of silently gaining -// capability across the fleet. -type FleetAppGrant struct { - Cap string `json:"cap"` - Target string `json:"target"` -} - -func (grant FleetAppGrant) Validate() error { - if !boundedFleetText(grant.Cap, 64, false) || !boundedFleetText(grant.Target, 512, true) { - return fmt.Errorf("authority: invalid fleet app grant") - } - return nil -} - -// FleetAppSpec is one desired app. Version pins what the node resolves out of -// the publisher-signed catalogue; the bundle's per-platform sha256 stays in -// that catalogue rather than here, because one desired-state document is -// distributed unchanged to a mixed-platform fleet. -type FleetAppSpec struct { - ID string `json:"id"` - Version string `json:"version"` - AcceptedGrants []FleetAppGrant `json:"accepted_grants"` - ApprovedBy string `json:"approved_by"` - ApprovedAt int64 `json:"approved_at"` -} - -func (spec FleetAppSpec) Validate() error { - if !fleetAppIDPattern.MatchString(spec.ID) || len(spec.ID) > 128 { - return fmt.Errorf("authority: invalid fleet app id") - } - if !fleetAppVersionPattern.MatchString(spec.Version) { - return fmt.Errorf("authority: invalid fleet app version for %s", spec.ID) - } - if err := validateIdentifier("fleet app approver", spec.ApprovedBy); err != nil { - return err - } - if spec.ApprovedAt <= 0 { - return fmt.Errorf("authority: fleet app %s carries no approval time", spec.ID) - } - if len(spec.AcceptedGrants) > MaxFleetAppGrants { - return fmt.Errorf("authority: fleet app %s declares too many accepted grants", spec.ID) - } - seen := make(map[string]struct{}, len(spec.AcceptedGrants)) - for _, grant := range spec.AcceptedGrants { - if err := grant.Validate(); err != nil { - return err - } - key := grant.Cap + "\x00" + grant.Target - if _, exists := seen[key]; exists { - return fmt.Errorf("authority: fleet app %s repeats an accepted grant", spec.ID) - } - seen[key] = struct{}{} - } - return nil -} - -// FleetAppsDocument is the complete desired app set for one node. It is -// declarative on purpose: the node reconciles toward it and is free to be -// offline, restarted, or rebuilt from an empty disk in between. A container -// fleet member whose filesystem is discarded on restart converges to the same -// set without the authority replaying anything. -type FleetAppsDocument struct { - Version uint16 `json:"version"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - Desired []FleetAppSpec `json:"desired"` - Reason string `json:"reason"` - IssuedAt int64 `json:"issued_at"` -} - -func (document FleetAppsDocument) Validate() error { - if document.Version != FleetAppsVersion || document.IssuedAt <= 0 { - return fmt.Errorf("authority: invalid fleet apps document") - } - for name, value := range map[string]string{"tenant_id": document.TenantID, "agent_id": document.AgentID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if !boundedFleetText(document.Reason, MaxFleetAppReasonSize, false) || len(strings.TrimSpace(document.Reason)) < 8 { - return fmt.Errorf("authority: invalid fleet apps document reason") - } - if len(document.Desired) > MaxFleetAppsEntries { - return fmt.Errorf("authority: fleet apps document exceeds %d entries", MaxFleetAppsEntries) - } - seen := make(map[string]struct{}, len(document.Desired)) - for _, spec := range document.Desired { - if err := spec.Validate(); err != nil { - return err - } - if _, exists := seen[spec.ID]; exists { - return fmt.Errorf("authority: fleet apps document repeats %s", spec.ID) - } - seen[spec.ID] = struct{}{} - } - return nil -} - -// Normalize orders the desired set so that re-approving an unchanged fleet -// produces a byte-identical document. Without this the console would queue a -// mutation, and the node would report a new revision, every time an operator -// opened the page and pressed save with nothing changed. -func (document *FleetAppsDocument) Normalize() { - sort.Slice(document.Desired, func(i, j int) bool { return document.Desired[i].ID < document.Desired[j].ID }) - for index := range document.Desired { - grants := document.Desired[index].AcceptedGrants - sort.Slice(grants, func(i, j int) bool { - if grants[i].Cap != grants[j].Cap { - return grants[i].Cap < grants[j].Cap - } - return grants[i].Target < grants[j].Target - }) - } -} - -// FleetAppState is one app as the node actually found it, reported back -// through the ordinary fleet state mirror rather than a new channel. -// -// DeclaredGrants is what the installed bundle's manifest actually asks for. -// The catalogue does not publish grants — they exist only inside the signed -// bundle — so the fleet is the only truthful source for them. A node that -// installs an app the tenant has not yet reviewed reports the grants here and -// holds the app unstarted, which lets the console show an operator the real -// capability list before anyone accepts it. -type FleetAppState struct { - ID string `json:"id"` - Version string `json:"version"` - Status string `json:"status"` - Detail string `json:"detail,omitempty"` - BinarySHA256 string `json:"binary_sha256,omitempty"` - DeclaredGrants []FleetAppGrant `json:"declared_grants,omitempty"` - ObservedAt int64 `json:"observed_at"` -} - -const ( - FleetAppInstalled = "installed" - FleetAppPending = "pending" - FleetAppFailed = "failed" - FleetAppGrantBlocked = "grant_blocked" -) - -func (state FleetAppState) Validate() error { - if !fleetAppIDPattern.MatchString(state.ID) || state.ObservedAt <= 0 { - return fmt.Errorf("authority: invalid fleet app state") - } - switch state.Status { - case FleetAppInstalled, FleetAppPending, FleetAppFailed, FleetAppGrantBlocked: - default: - return fmt.Errorf("authority: invalid fleet app status for %s", state.ID) - } - if state.Version != "" && !fleetAppVersionPattern.MatchString(state.Version) { - return fmt.Errorf("authority: invalid fleet app state version for %s", state.ID) - } - if state.BinarySHA256 != "" && !lowerHexIdentifier(state.BinarySHA256, 64) { - return fmt.Errorf("authority: invalid fleet app binary digest for %s", state.ID) - } - if !boundedFleetText(state.Detail, 512, true) || !utf8.ValidString(state.Detail) { - return fmt.Errorf("authority: invalid fleet app state detail for %s", state.ID) - } - if len(state.DeclaredGrants) > MaxFleetAppGrants { - return fmt.Errorf("authority: fleet app %s reports too many declared grants", state.ID) - } - for _, grant := range state.DeclaredGrants { - if err := grant.Validate(); err != nil { - return err - } - } - return nil -} - -// FleetAppsReport is the node-authored inventory document. The node writes it -// into its own state tree, so it arrives through the existing signed snapshot -// and needs no separate endpoint, storage, or retention policy. -type FleetAppsReport struct { - Version uint16 `json:"version"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - Apps []FleetAppState `json:"apps"` - ObservedAt int64 `json:"observed_at"` -} - -// FleetAppsReportPath is where the node publishes its inventory. It is -// deliberately distinct from FleetAppsDocumentPath: the authority owns the -// desired set and the node owns the observed set, so neither overwrites the -// other and drift between them is visible rather than resolved silently. -const FleetAppsReportPath = "apps-observed.json" - -func (report FleetAppsReport) Validate() error { - if report.Version != FleetAppsVersion || report.ObservedAt <= 0 { - return fmt.Errorf("authority: invalid fleet apps report") - } - for name, value := range map[string]string{"tenant_id": report.TenantID, "agent_id": report.AgentID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if len(report.Apps) > MaxFleetAppsEntries { - return fmt.Errorf("authority: fleet apps report exceeds %d entries", MaxFleetAppsEntries) - } - for _, state := range report.Apps { - if err := state.Validate(); err != nil { - return err - } - } - return nil -} - -// GrantsCovered reports whether every grant an installed manifest declares is -// covered by what the administrator accepted. The node calls this before it -// lets the supervisor start an app; the authority calls it to show drift in -// the console. Both must agree, so the comparison lives here rather than in -// either caller. -func GrantsCovered(declared, accepted []FleetAppGrant) bool { - allowed := make(map[string]struct{}, len(accepted)) - for _, grant := range accepted { - allowed[grant.Cap+"\x00"+grant.Target] = struct{}{} - } - for _, grant := range declared { - if _, ok := allowed[grant.Cap+"\x00"+grant.Target]; !ok { - return false - } - } - return true -} diff --git a/internal/managedsdk/authority/fleet_control.go b/internal/managedsdk/authority/fleet_control.go deleted file mode 100644 index 77b51c7e..00000000 --- a/internal/managedsdk/authority/fleet_control.go +++ /dev/null @@ -1,478 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package authority - -import ( - "context" - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "sort" - "strings" - "sync" - "time" - "unicode/utf8" - - "github.com/pilot-protocol/common/decision" -) - -const ( - FleetNodeControlVersion uint16 = 1 - FleetActivityVersion uint16 = 1 - FleetNodeControlDomain = "pilot-fleet-node-control-v1" - FleetActivityDomain = "pilot-fleet-activity-v1" - FleetControlAckVersion uint16 = 1 - FleetControlAckDomain = "pilot-fleet-control-ack-v1" - MaxFleetControlTags = 16 -) - -// FleetNodeControl is the signed desired state for one enrolled node. It is a -// deliberately small replacement for arbitrary remote shell access: operators -// can organize a node, declare the expected runtime and policy revision, or -// quarantine it. The node and the authority both verify this object before -// treating it as current control state. -type FleetNodeControl struct { - Version uint16 `json:"version"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - Revision uint64 `json:"revision"` - Group string `json:"group,omitempty"` - Tags []string `json:"tags,omitempty"` - DesiredVersion string `json:"desired_version,omitempty"` - DesiredPolicyRevision uint64 `json:"desired_policy_revision,omitempty"` - Quarantined bool `json:"quarantined"` - Reason string `json:"reason"` - IssuedAt int64 `json:"issued_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -const ( - FleetControlApplied = "applied" - FleetControlPartiallyApplied = "partially_applied" - FleetControlRejected = "rejected" -) - -// FleetControlAcknowledgement is signed by the enrolled node after it has -// reconciled one exact desired-state revision. It distinguishes a published -// control from a control the workload actually verified and applied. -type FleetControlAcknowledgement struct { - Version uint16 `json:"version"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - ControlRevision uint64 `json:"control_revision"` - Status string `json:"status"` - DetailCode string `json:"detail_code,omitempty"` - AppliedPolicyRevision uint64 `json:"applied_policy_revision,omitempty"` - RunningVersion string `json:"running_version,omitempty"` - Quarantined bool `json:"quarantined"` - ObservedAt int64 `json:"observed_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func (ack FleetControlAcknowledgement) Validate() error { - if ack.Version != FleetControlAckVersion || ack.ControlRevision == 0 || ack.ObservedAt <= 0 { - return fmt.Errorf("authority: invalid fleet control acknowledgement version, revision, or time") - } - for name, value := range map[string]string{"tenant_id": ack.TenantID, "agent_id": ack.AgentID, "key_id": ack.KeyID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - switch ack.Status { - case FleetControlApplied: - if ack.DetailCode != "" { - return fmt.Errorf("authority: applied fleet control acknowledgement has a detail code") - } - case FleetControlPartiallyApplied, FleetControlRejected: - if err := validateIdentifier("detail_code", ack.DetailCode); err != nil { - return err - } - default: - return fmt.Errorf("authority: invalid fleet control acknowledgement status") - } - if !boundedFleetText(ack.RunningVersion, 128, true) { - return fmt.Errorf("authority: invalid acknowledged runtime version") - } - return nil -} - -func (ack FleetControlAcknowledgement) Canonical() ([]byte, error) { - if err := ack.Validate(); err != nil { - return nil, err - } - writer := canonicalWriter{} - writer.string(FleetControlAckDomain) - writer.u16(ack.Version) - writer.string(ack.TenantID) - writer.string(ack.AgentID) - writer.u64(ack.ControlRevision) - writer.string(ack.Status) - writer.string(ack.DetailCode) - writer.u64(ack.AppliedPolicyRevision) - writer.string(ack.RunningVersion) - if ack.Quarantined { - writer.u16(1) - } else { - writer.u16(0) - } - writer.i64(ack.ObservedAt) - writer.string(ack.KeyID) - if writer.err != nil { - return nil, writer.err - } - return writer.Buffer.Bytes(), nil -} - -func (ack *FleetControlAcknowledgement) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authority: invalid fleet control acknowledgement signing key") - } - canonical, err := ack.Canonical() - if err != nil { - return err - } - ack.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (ack FleetControlAcknowledgement) Verify(publicKey ed25519.PublicKey, now time.Time) error { - if err := ack.Validate(); err != nil { - return err - } - if len(publicKey) != ed25519.PublicKeySize || ack.ObservedAt > now.Unix()+int64(MaxBundleClockSkew/time.Second) || ack.ObservedAt < now.Add(-24*time.Hour).Unix() { - return fmt.Errorf("authority: fleet control acknowledgement is outside its accepted observation window") - } - canonical, err := ack.Canonical() - if err != nil { - return err - } - signature, err := base64.StdEncoding.DecodeString(ack.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("authority: invalid fleet control acknowledgement signature") - } - return nil -} - -func (control FleetNodeControl) Validate() error { - if control.Version != FleetNodeControlVersion || control.Revision == 0 || control.IssuedAt <= 0 { - return fmt.Errorf("authority: invalid fleet node control version, revision, or issue time") - } - for name, value := range map[string]string{"tenant_id": control.TenantID, "agent_id": control.AgentID, "key_id": control.KeyID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if control.Group != "" { - if err := validateIdentifier("fleet group", control.Group); err != nil { - return err - } - } - if len(control.Tags) > MaxFleetControlTags { - return fmt.Errorf("authority: fleet control has too many tags") - } - tags := append([]string(nil), control.Tags...) - sort.Strings(tags) - for index, tag := range tags { - if err := validateIdentifier("fleet tag", tag); err != nil { - return err - } - if index > 0 && tags[index-1] == tag { - return fmt.Errorf("authority: duplicate fleet control tag") - } - } - if !boundedFleetText(control.DesiredVersion, 128, true) || !boundedFleetText(control.Reason, 256, false) || len(strings.TrimSpace(control.Reason)) < 8 { - return fmt.Errorf("authority: invalid fleet control version or reason") - } - return nil -} - -func (control FleetNodeControl) Canonical() ([]byte, error) { - if err := control.Validate(); err != nil { - return nil, err - } - tags := append([]string(nil), control.Tags...) - sort.Strings(tags) - writer := canonicalWriter{} - writer.string(FleetNodeControlDomain) - writer.u16(control.Version) - writer.string(control.TenantID) - writer.string(control.AgentID) - writer.u64(control.Revision) - writer.string(control.Group) - // #nosec G115 -- Validate limits tags below uint16 capacity. - writer.u16(uint16(len(tags))) - for _, tag := range tags { - writer.string(tag) - } - writer.string(control.DesiredVersion) - writer.u64(control.DesiredPolicyRevision) - if control.Quarantined { - writer.u16(1) - } else { - writer.u16(0) - } - writer.string(control.Reason) - writer.i64(control.IssuedAt) - writer.string(control.KeyID) - if writer.err != nil { - return nil, writer.err - } - return writer.Buffer.Bytes(), nil -} - -func (control *FleetNodeControl) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authority: invalid fleet control signing key") - } - canonical, err := control.Canonical() - if err != nil { - return err - } - control.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (control FleetNodeControl) Verify(publicKey ed25519.PublicKey, now time.Time) error { - if err := control.Validate(); err != nil { - return err - } - if len(publicKey) != ed25519.PublicKeySize || control.IssuedAt > now.Unix()+int64(MaxBundleClockSkew/time.Second) { - return fmt.Errorf("authority: invalid fleet control key or issue time") - } - canonical, err := control.Canonical() - if err != nil { - return err - } - signature, err := base64.StdEncoding.DecodeString(control.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("authority: invalid fleet control signature") - } - return nil -} - -// FleetActivity is privacy-bounded, signed operational telemetry for a -// governed action. It carries the action and coarse resource class but never a -// resource identifier, destination, prompt, payload, path, or environment -// value. Signed receipts remain the authoritative execution evidence. -type FleetActivity struct { - Version uint16 `json:"version"` - ID string `json:"id"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - Action string `json:"action"` - ResourceClass string `json:"resource_class"` - Risk decision.RiskClass `json:"risk"` - Outcome decision.Outcome `json:"outcome"` - Result decision.EnforcementResult `json:"result"` - IntentHash string `json:"intent_hash"` - DecisionID string `json:"decision_id"` - DurationMillis int64 `json:"duration_millis"` - ObservedAt int64 `json:"observed_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func NewFleetActivity(intent decision.Intent, result decision.Decision, enforcement decision.EnforcementResult, duration time.Duration, keyID string, observedAt int64) (FleetActivity, error) { - intentHash, err := intent.Hash() - if err != nil { - return FleetActivity{}, err - } - resourceClass := "resource" - if prefix, _, found := strings.Cut(intent.Resource, ":"); found && prefix != "" { - resourceClass = prefix - } - digest := sha256.Sum256([]byte(FleetActivityDomain + "\x00" + intent.TenantID + "\x00" + intent.AgentID + "\x00" + intentHash + "\x00" + result.ID)) - activity := FleetActivity{ - Version: FleetActivityVersion, ID: hex.EncodeToString(digest[:]), TenantID: intent.TenantID, AgentID: intent.AgentID, - Action: intent.Action, ResourceClass: resourceClass, Risk: intent.Risk, Outcome: result.Outcome, Result: enforcement, - IntentHash: intentHash, DecisionID: result.ID, DurationMillis: duration.Milliseconds(), ObservedAt: observedAt, KeyID: keyID, - } - if err := activity.Validate(); err != nil { - return FleetActivity{}, err - } - return activity, nil -} - -func (activity FleetActivity) Validate() error { - if activity.Version != FleetActivityVersion || !lowerHexIdentifier(activity.ID, 64) || !lowerHexIdentifier(activity.IntentHash, 64) { - return fmt.Errorf("authority: invalid fleet activity identity") - } - for name, value := range map[string]string{ - "tenant_id": activity.TenantID, "agent_id": activity.AgentID, "action": activity.Action, - "resource_class": activity.ResourceClass, "decision_id": activity.DecisionID, "key_id": activity.KeyID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - switch activity.Risk { - case decision.RiskLow, decision.RiskMedium, decision.RiskHigh, decision.RiskCritical: - default: - return fmt.Errorf("authority: invalid fleet activity risk") - } - switch activity.Outcome { - case decision.Allow, decision.Deny, decision.Constrain, decision.ApprovalRequired: - default: - return fmt.Errorf("authority: invalid fleet activity outcome") - } - switch activity.Result { - case decision.Enforced, decision.Denied, decision.ApprovalPending, decision.Failed: - default: - return fmt.Errorf("authority: invalid fleet activity result") - } - if activity.DurationMillis < 0 || activity.DurationMillis > int64((24*time.Hour)/time.Millisecond) || activity.ObservedAt <= 0 { - return fmt.Errorf("authority: invalid fleet activity timing") - } - return nil -} - -func (activity FleetActivity) Canonical() ([]byte, error) { - if err := activity.Validate(); err != nil { - return nil, err - } - writer := canonicalWriter{} - writer.string(FleetActivityDomain) - writer.u16(activity.Version) - writer.string(activity.ID) - writer.string(activity.TenantID) - writer.string(activity.AgentID) - writer.string(activity.Action) - writer.string(activity.ResourceClass) - writer.string(string(activity.Risk)) - writer.string(string(activity.Outcome)) - writer.string(string(activity.Result)) - writer.string(activity.IntentHash) - writer.string(activity.DecisionID) - writer.i64(activity.DurationMillis) - writer.i64(activity.ObservedAt) - writer.string(activity.KeyID) - if writer.err != nil { - return nil, writer.err - } - return writer.Buffer.Bytes(), nil -} - -func (activity *FleetActivity) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authority: invalid fleet activity signing key") - } - canonical, err := activity.Canonical() - if err != nil { - return err - } - activity.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (activity FleetActivity) Verify(publicKey ed25519.PublicKey, now time.Time) error { - if err := activity.Validate(); err != nil { - return err - } - if len(publicKey) != ed25519.PublicKeySize || activity.ObservedAt > now.Unix()+int64(MaxBundleClockSkew/time.Second) || activity.ObservedAt < now.Add(-24*time.Hour).Unix() { - return fmt.Errorf("authority: fleet activity is outside its accepted observation window") - } - canonical, err := activity.Canonical() - if err != nil { - return err - } - signature, err := base64.StdEncoding.DecodeString(activity.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("authority: invalid fleet activity signature") - } - return nil -} - -func boundedFleetText(value string, maximum int, allowEmpty bool) bool { - if (!allowEmpty && strings.TrimSpace(value) == "") || len(value) > maximum || !utf8.ValidString(value) { - return false - } - for _, character := range value { - if character < 0x20 || character == 0x7f { - return false - } - } - return true -} - -// FleetControlAuthorizer applies the cached signed quarantine ceiling after -// deterministic/semantic policy evaluation. A quarantine can only narrow -// authority; a missing control record leaves ordinary open-agent behavior -// unchanged. -type FleetControlAuthorizer struct { - Base decision.Authorizer - Quarantined func(string, string) bool -} - -func (authorizer FleetControlAuthorizer) Authorize(ctx context.Context, intent decision.Intent) (decision.Decision, error) { - result, err := authorizer.Base.Authorize(ctx, intent) - return authorizer.apply(intent, result, err) -} - -func (authorizer FleetControlAuthorizer) AuthorizeDisclosure(ctx context.Context, intent decision.Intent, disclosure decision.DisclosureBinding) (decision.Decision, error) { - aware, ok := authorizer.Base.(decision.DisclosureAuthorizer) - if !ok { - return decision.Decision{}, fmt.Errorf("authority: base authorizer does not support disclosure binding") - } - result, err := aware.AuthorizeDisclosure(ctx, intent, disclosure) - return authorizer.apply(intent, result, err) -} - -// AuthorizeFederatedContent preserves the hosted full-content evaluation path -// through the fleet quarantine ceiling. Wrapping an evaluator must never erase -// a capability required by the account federation ingress. -func (authorizer FleetControlAuthorizer) AuthorizeFederatedContent(ctx context.Context, intent decision.Intent, content decision.FederatedContent) (decision.Decision, error) { - aware, ok := authorizer.Base.(decision.FederatedContentAuthorizer) - if !ok { - return decision.Decision{}, fmt.Errorf("authority: base authorizer does not support federated content") - } - result, err := aware.AuthorizeFederatedContent(ctx, intent, content) - return authorizer.apply(intent, result, err) -} - -func (authorizer FleetControlAuthorizer) apply(intent decision.Intent, result decision.Decision, err error) (decision.Decision, error) { - if err != nil { - return decision.Decision{}, err - } - if authorizer.Quarantined == nil || !authorizer.Quarantined(intent.TenantID, intent.AgentID) { - return result, nil - } - result.Outcome = decision.Deny - result.Constraints = nil - result.Reasons = append(result.Reasons, "fleet_quarantine") - return result, nil -} - -type fleetControlCache struct { - mu sync.RWMutex - controls map[string]FleetNodeControl -} - -func (cache *fleetControlCache) replace(controls []FleetNodeControl) { - current := make(map[string]FleetNodeControl, len(controls)) - for _, control := range controls { - current[control.TenantID+"\x00"+control.AgentID] = control - } - cache.mu.Lock() - cache.controls = current - cache.mu.Unlock() -} - -func (cache *fleetControlCache) put(control FleetNodeControl) { - cache.mu.Lock() - if cache.controls == nil { - cache.controls = make(map[string]FleetNodeControl) - } - cache.controls[control.TenantID+"\x00"+control.AgentID] = control - cache.mu.Unlock() -} - -func (cache *fleetControlCache) quarantined(tenantID, agentID string) bool { - cache.mu.RLock() - control, found := cache.controls[tenantID+"\x00"+agentID] - cache.mu.RUnlock() - return found && control.Quarantined -} diff --git a/internal/managedsdk/authority/fleet_state.go b/internal/managedsdk/authority/fleet_state.go deleted file mode 100644 index 85cc0fd7..00000000 --- a/internal/managedsdk/authority/fleet_state.go +++ /dev/null @@ -1,482 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package authority - -import ( - "context" - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "path" - "sort" - "strings" - "time" - "unicode/utf8" -) - -const ( - FleetStateVersion uint16 = 1 - FleetStateSnapshotDomain = "pilot-fleet-state-snapshot-v1" - FleetStateMutationDomain = "pilot-fleet-state-mutation-v1" - FleetStateMutationResultDomain = "pilot-fleet-state-mutation-result-v1" - MaxFleetStateEntries = 10_000 - MaxFleetStateVisibleBytes = 8 << 20 - MaxFleetStateMutationBytes = 4 << 20 - MaxFleetStateMutationOperations = 256 -) - -type FleetStateEntryKind string - -const ( - FleetStateDirectory FleetStateEntryKind = "directory" - FleetStateFile FleetStateEntryKind = "file" - FleetStateSymlink FleetStateEntryKind = "symlink" -) - -// FleetStateEntry is one live .pilot tree entry. Hash always identifies the -// complete local file. Content is a bounded full file or preview; protected -// values and symlinks are represented by metadata/fingerprint only. -type FleetStateEntry struct { - Path string `json:"path"` - Kind FleetStateEntryKind `json:"kind"` - Mode uint32 `json:"mode"` - Size uint64 `json:"size"` - ModifiedAt int64 `json:"modified_at"` - Hash string `json:"hash,omitempty"` - Content []byte `json:"content,omitempty"` - ContentOffset uint64 `json:"content_offset,omitempty"` - ContentHash string `json:"content_hash,omitempty"` - Truncated bool `json:"truncated,omitempty"` - Redacted bool `json:"redacted,omitempty"` - Protected bool `json:"protected,omitempty"` - ProtectionReason string `json:"protection_reason,omitempty"` -} - -func (entry FleetStateEntry) Validate() error { - if !validFleetStatePath(entry.Path) || entry.Mode > 0o7777 || entry.ModifiedAt <= 0 { - return fmt.Errorf("authority: invalid fleet state entry path, mode, or modification time") - } - switch entry.Kind { - case FleetStateDirectory: - if entry.Size != 0 || entry.Hash != "" || len(entry.Content) != 0 || entry.ContentHash != "" || entry.ContentOffset != 0 || entry.Truncated || entry.Redacted || entry.Protected { - return fmt.Errorf("authority: directory fleet state entry carries file fields") - } - case FleetStateSymlink: - if !entry.Protected || entry.Hash == "" || len(entry.Content) != 0 || entry.ContentHash != "" { - return fmt.Errorf("authority: symlink fleet state entry must be metadata-only") - } - case FleetStateFile: - if !lowerHexIdentifier(entry.Hash, 64) { - return fmt.Errorf("authority: fleet state file requires a complete SHA-256 hash") - } - if entry.Protected { - if len(entry.Content) != 0 || entry.ContentHash != "" || entry.ContentOffset != 0 || entry.Truncated || entry.Redacted || !boundedFleetText(entry.ProtectionReason, 128, false) { - return fmt.Errorf("authority: invalid protected fleet state entry") - } - } else { - if len(entry.Content) == 0 && entry.Size != 0 || len(entry.Content) > MaxFleetStateVisibleBytes || !lowerHexIdentifier(entry.ContentHash, 64) { - return fmt.Errorf("authority: invalid visible fleet state content") - } - sum := sha256.Sum256(entry.Content) - if hex.EncodeToString(sum[:]) != entry.ContentHash { - return fmt.Errorf("authority: fleet state visible content binding mismatch") - } - if entry.Redacted { - if entry.ContentOffset != 0 || entry.Truncated { - return fmt.Errorf("authority: redacted fleet state content must be a complete sanitized representation") - } - } else if entry.ContentOffset+uint64(len(entry.Content)) > entry.Size { - return fmt.Errorf("authority: fleet state visible content exceeds original file size") - } - if !entry.Truncated && !entry.Redacted && (entry.ContentOffset != 0 || uint64(len(entry.Content)) != entry.Size || entry.ContentHash != entry.Hash) { - return fmt.Errorf("authority: complete fleet state content differs from file hash") - } - } - default: - return fmt.Errorf("authority: invalid fleet state entry kind") - } - return nil -} - -type FleetStateSnapshot struct { - Version uint16 `json:"version"` - ID string `json:"id"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - Revision uint64 `json:"revision"` - RootHash string `json:"root_hash"` - Entries []FleetStateEntry `json:"entries"` - ObservedAt int64 `json:"observed_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func NewFleetStateSnapshot(tenantID, agentID string, revision uint64, entries []FleetStateEntry, observedAt time.Time, keyID string) (FleetStateSnapshot, error) { - snapshot := FleetStateSnapshot{ - Version: FleetStateVersion, TenantID: tenantID, AgentID: agentID, Revision: revision, - Entries: cloneFleetStateEntries(entries), ObservedAt: observedAt.UTC().Unix(), KeyID: keyID, - } - sort.Slice(snapshot.Entries, func(i, j int) bool { return snapshot.Entries[i].Path < snapshot.Entries[j].Path }) - root, err := fleetStateRootHash(snapshot.Entries) - if err != nil { - return FleetStateSnapshot{}, err - } - snapshot.RootHash = root - digest := sha256.Sum256([]byte(FleetStateSnapshotDomain + "\x00" + tenantID + "\x00" + agentID + "\x00" + fmt.Sprint(revision) + "\x00" + root)) - snapshot.ID = "state-" + hex.EncodeToString(digest[:16]) - if err := snapshot.Validate(); err != nil { - return FleetStateSnapshot{}, err - } - return snapshot, nil -} - -func (snapshot FleetStateSnapshot) Validate() error { - if snapshot.Version != FleetStateVersion || snapshot.Revision == 0 || snapshot.ObservedAt <= 0 || !lowerHexIdentifier(snapshot.RootHash, 64) || len(snapshot.Entries) > MaxFleetStateEntries { - return fmt.Errorf("authority: invalid fleet state snapshot") - } - for name, value := range map[string]string{"id": snapshot.ID, "tenant_id": snapshot.TenantID, "agent_id": snapshot.AgentID, "key_id": snapshot.KeyID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - visible := 0 - last := "" - for _, entry := range snapshot.Entries { - if err := entry.Validate(); err != nil { - return err - } - if last != "" && last >= entry.Path { - return fmt.Errorf("authority: fleet state entries must be uniquely sorted") - } - last = entry.Path - visible += len(entry.Content) - if visible > MaxFleetStateVisibleBytes { - return fmt.Errorf("authority: fleet state visible content exceeds snapshot limit") - } - } - root, err := fleetStateRootHash(snapshot.Entries) - if err != nil || root != snapshot.RootHash { - return fmt.Errorf("authority: fleet state root hash mismatch") - } - return nil -} - -func (snapshot FleetStateSnapshot) Canonical() ([]byte, error) { - if err := snapshot.Validate(); err != nil { - return nil, err - } - w := canonicalWriter{} - w.string(FleetStateSnapshotDomain) - w.u16(snapshot.Version) - w.string(snapshot.ID) - w.string(snapshot.TenantID) - w.string(snapshot.AgentID) - w.u64(snapshot.Revision) - w.string(snapshot.RootHash) - w.i64(snapshot.ObservedAt) - w.string(snapshot.KeyID) - return w.Buffer.Bytes(), w.err -} - -func (snapshot *FleetStateSnapshot) Sign(privateKey ed25519.PrivateKey) error { - canonical, err := snapshot.Canonical() - if err != nil || len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authority: invalid fleet state snapshot signing input") - } - snapshot.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (snapshot FleetStateSnapshot) Verify(publicKey ed25519.PublicKey, now time.Time) error { - if err := snapshot.VerifySignature(publicKey); err != nil { - return err - } - if snapshot.ObservedAt > now.Unix()+int64(MaxBundleClockSkew/time.Second) || snapshot.ObservedAt < now.Add(-24*time.Hour).Unix() { - return fmt.Errorf("authority: fleet state snapshot is outside its accepted observation window") - } - return nil -} - -// VerifySignature authenticates a retained snapshot without treating its -// observation time as a live admission credential. Ingestion uses Verify; -// history reads and revision recovery use this method. -func (snapshot FleetStateSnapshot) VerifySignature(publicKey ed25519.PublicKey) error { - canonical, err := snapshot.Canonical() - if err != nil { - return err - } - signature, err := base64.StdEncoding.DecodeString(snapshot.Signature) - if len(publicKey) != ed25519.PublicKeySize || err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("authority: invalid fleet state snapshot signature") - } - return nil -} - -func fleetStateRootHash(entries []FleetStateEntry) (string, error) { - hash := sha256.New() - for _, entry := range entries { - if err := entry.Validate(); err != nil { - return "", err - } - for _, value := range []string{entry.Path, string(entry.Kind), fmt.Sprint(entry.Mode), fmt.Sprint(entry.Size), fmt.Sprint(entry.ModifiedAt), entry.Hash, fmt.Sprint(entry.ContentOffset), entry.ContentHash, fmt.Sprint(entry.Truncated), fmt.Sprint(entry.Redacted), fmt.Sprint(entry.Protected), entry.ProtectionReason} { - _, _ = hash.Write([]byte(fmt.Sprintf("%08x", len(value)))) - _, _ = hash.Write([]byte(value)) - } - } - return hex.EncodeToString(hash.Sum(nil)), nil -} - -type FleetStateMutationKind string - -const ( - FleetStatePutFile FleetStateMutationKind = "put_file" - FleetStateDeletePath FleetStateMutationKind = "delete_path" - FleetStateMakeDirectory FleetStateMutationKind = "make_directory" -) - -type FleetStateMutationOperation struct { - Kind FleetStateMutationKind `json:"kind"` - Path string `json:"path"` - ExpectedHash string `json:"expected_hash,omitempty"` - Content []byte `json:"content,omitempty"` - ContentHash string `json:"content_hash,omitempty"` - Mode uint32 `json:"mode,omitempty"` -} - -func (operation FleetStateMutationOperation) Validate() error { - if !validFleetStatePath(operation.Path) || operation.Mode > 0o777 { - return fmt.Errorf("authority: invalid fleet state mutation path or mode") - } - if operation.ExpectedHash != "" && !lowerHexIdentifier(operation.ExpectedHash, 64) { - return fmt.Errorf("authority: invalid fleet state expected hash") - } - switch operation.Kind { - case FleetStatePutFile: - if len(operation.Content) > MaxFleetStateMutationBytes || !lowerHexIdentifier(operation.ContentHash, 64) { - return fmt.Errorf("authority: invalid fleet state put operation") - } - sum := sha256.Sum256(operation.Content) - if hex.EncodeToString(sum[:]) != operation.ContentHash { - return fmt.Errorf("authority: fleet state put content hash mismatch") - } - case FleetStateDeletePath, FleetStateMakeDirectory: - if len(operation.Content) != 0 || operation.ContentHash != "" { - return fmt.Errorf("authority: non-put fleet state mutation carries content") - } - default: - return fmt.Errorf("authority: unsupported fleet state mutation operation") - } - return nil -} - -type FleetStateMutation struct { - Version uint16 `json:"version"` - ID string `json:"id"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - ExpectedRevision uint64 `json:"expected_revision"` - Operations []FleetStateMutationOperation `json:"operations"` - Reason string `json:"reason"` - IssuedAt int64 `json:"issued_at"` - ExpiresAt int64 `json:"expires_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func (mutation FleetStateMutation) Validate() error { - if mutation.Version != FleetStateVersion || mutation.ExpectedRevision == 0 || len(mutation.Operations) == 0 || len(mutation.Operations) > MaxFleetStateMutationOperations || mutation.IssuedAt <= 0 || mutation.ExpiresAt <= mutation.IssuedAt || mutation.ExpiresAt-mutation.IssuedAt > int64(MaxFleetCommandTTL/time.Second) { - return fmt.Errorf("authority: invalid fleet state mutation") - } - for name, value := range map[string]string{"id": mutation.ID, "tenant_id": mutation.TenantID, "agent_id": mutation.AgentID, "key_id": mutation.KeyID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if !boundedFleetText(mutation.Reason, 256, false) || len(strings.TrimSpace(mutation.Reason)) < 8 { - return fmt.Errorf("authority: invalid fleet state mutation reason") - } - total := 0 - seen := make(map[string]struct{}, len(mutation.Operations)) - for _, operation := range mutation.Operations { - if err := operation.Validate(); err != nil { - return err - } - if _, duplicate := seen[operation.Path]; duplicate { - return fmt.Errorf("authority: duplicate fleet state mutation path") - } - seen[operation.Path] = struct{}{} - total += len(operation.Content) - if total > MaxFleetStateMutationBytes { - return fmt.Errorf("authority: fleet state mutation content exceeds limit") - } - } - return nil -} - -func (mutation FleetStateMutation) Canonical() ([]byte, error) { - if err := mutation.Validate(); err != nil { - return nil, err - } - w := canonicalWriter{} - w.string(FleetStateMutationDomain) - w.u16(mutation.Version) - w.string(mutation.ID) - w.string(mutation.TenantID) - w.string(mutation.AgentID) - w.u64(mutation.ExpectedRevision) - // #nosec G115 -- Validate limits operations below uint16 capacity. - w.u16(uint16(len(mutation.Operations))) - for _, operation := range mutation.Operations { - w.string(string(operation.Kind)) - w.string(operation.Path) - w.string(operation.ExpectedHash) - w.string(operation.ContentHash) - w.u64(uint64(operation.Mode)) - } - w.string(mutation.Reason) - w.i64(mutation.IssuedAt) - w.i64(mutation.ExpiresAt) - w.string(mutation.KeyID) - return w.Buffer.Bytes(), w.err -} - -func (mutation *FleetStateMutation) SignWith(sign func([]byte) ([]byte, error)) error { - canonical, err := mutation.Canonical() - if err != nil { - return err - } - signature, err := sign(canonical) - if err != nil || len(signature) != ed25519.SignatureSize { - return fmt.Errorf("authority: fleet state mutation signing failed") - } - mutation.Signature = base64.StdEncoding.EncodeToString(signature) - return nil -} - -func (mutation FleetStateMutation) Verify(publicKey ed25519.PublicKey, now time.Time) error { - canonical, err := mutation.Canonical() - if err != nil { - return err - } - if len(publicKey) != ed25519.PublicKeySize || now.Unix() < mutation.IssuedAt-int64(MaxBundleClockSkew/time.Second) || now.Unix() > mutation.ExpiresAt+int64(MaxBundleClockSkew/time.Second) { - return fmt.Errorf("authority: fleet state mutation is outside its validity window") - } - signature, err := base64.StdEncoding.DecodeString(mutation.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("authority: invalid fleet state mutation signature") - } - return nil -} - -type FleetStateMutationResult struct { - Version uint16 `json:"version"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - MutationID string `json:"mutation_id"` - Status string `json:"status"` - DetailCode string `json:"detail_code,omitempty"` - FromRevision uint64 `json:"from_revision"` - ToRevision uint64 `json:"to_revision"` - ObservedAt int64 `json:"observed_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func (result FleetStateMutationResult) Validate() error { - if result.Version != FleetStateVersion || result.FromRevision == 0 || result.ObservedAt <= 0 { - return fmt.Errorf("authority: invalid fleet state mutation result") - } - for name, value := range map[string]string{"tenant_id": result.TenantID, "agent_id": result.AgentID, "mutation_id": result.MutationID, "key_id": result.KeyID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - switch result.Status { - case "applied": - if result.ToRevision <= result.FromRevision || result.DetailCode != "" { - return fmt.Errorf("authority: applied fleet state mutation has invalid revision or detail") - } - case "rejected", "failed": - if result.ToRevision != result.FromRevision || validateIdentifier("detail_code", result.DetailCode) != nil { - return fmt.Errorf("authority: failed fleet state mutation result is invalid") - } - default: - return fmt.Errorf("authority: invalid fleet state mutation result status") - } - return nil -} - -func (result FleetStateMutationResult) Canonical() ([]byte, error) { - if err := result.Validate(); err != nil { - return nil, err - } - w := canonicalWriter{} - w.string(FleetStateMutationResultDomain) - w.u16(result.Version) - w.string(result.TenantID) - w.string(result.AgentID) - w.string(result.MutationID) - w.string(result.Status) - w.string(result.DetailCode) - w.u64(result.FromRevision) - w.u64(result.ToRevision) - w.i64(result.ObservedAt) - w.string(result.KeyID) - return w.Buffer.Bytes(), w.err -} - -func (result *FleetStateMutationResult) Sign(privateKey ed25519.PrivateKey) error { - canonical, err := result.Canonical() - if err != nil || len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authority: invalid fleet state mutation result signing input") - } - result.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (result FleetStateMutationResult) Verify(publicKey ed25519.PublicKey, now time.Time) error { - canonical, err := result.Canonical() - if err != nil { - return err - } - if len(publicKey) != ed25519.PublicKeySize || result.ObservedAt > now.Unix()+int64(MaxBundleClockSkew/time.Second) || result.ObservedAt < now.Add(-24*time.Hour).Unix() { - return fmt.Errorf("authority: fleet state mutation result is outside its accepted observation window") - } - signature, err := base64.StdEncoding.DecodeString(result.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("authority: invalid fleet state mutation result signature") - } - return nil -} - -type FleetStatePersistence interface { - SaveFleetStateSnapshot(context.Context, FleetStateSnapshot) error - FleetStateSnapshot(context.Context, string, string) (FleetStateSnapshot, bool, error) - ListFleetStateSnapshots(context.Context, string) ([]FleetStateSnapshot, error) - SaveFleetStateMutation(context.Context, FleetStateMutation) error - ListFleetStateMutations(context.Context, string, string) ([]FleetStateMutation, error) - SaveFleetStateMutationResult(context.Context, FleetStateMutationResult) error - ListFleetStateMutationResults(context.Context, string, string) ([]FleetStateMutationResult, error) -} - -type FleetStateHistoryPersistence interface { - ListFleetStateSnapshotHistory(context.Context, string, string, int) ([]FleetStateSnapshot, error) -} - -func validFleetStatePath(value string) bool { - if value == "" || value == "." || len(value) > 512 || !utf8.ValidString(value) || strings.ContainsAny(value, "\\\x00\r\n") || strings.HasPrefix(value, "/") { - return false - } - cleaned := path.Clean(value) - return cleaned == value && cleaned != ".." && !strings.HasPrefix(cleaned, "../") -} - -func cloneFleetStateEntries(entries []FleetStateEntry) []FleetStateEntry { - cloned := append([]FleetStateEntry(nil), entries...) - for index := range cloned { - cloned[index].Content = append([]byte(nil), cloned[index].Content...) - } - return cloned -} diff --git a/internal/managedsdk/authority/mandate_bundle.go b/internal/managedsdk/authority/mandate_bundle.go deleted file mode 100644 index f9a3355f..00000000 --- a/internal/managedsdk/authority/mandate_bundle.go +++ /dev/null @@ -1,274 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package authority - -import ( - "context" - "fmt" - "sort" - "strings" - "sync" - "time" - - "github.com/pilot-protocol/common/decision" -) - -// MandateBundleTrustStore resolves only tenant-wide mandate issuer keys and -// supplies the current revocation floor. Workload-scoped keys are deliberately -// unable to publish a distribution snapshot. -type MandateBundleTrustStore interface { - decision.MandateKeyResolver - decision.MandateStateResolver -} - -// MandateBundlePersistence makes mandate distribution restart-durable. A -// persistence implementation must retain the newest bundle per -// tenant/subject-agent pair and reject conflicting equal revisions. -type MandateBundlePersistence interface { - SaveMandateBundle(context.Context, decision.MandateBundle) error - LoadMandateBundles(context.Context) ([]decision.MandateBundle, error) -} - -// TenantMandateBundlePersistence can load only one authority domain from a -// shared backend. A single-tenant authority must not attempt to validate or -// serve another tenant's delegation snapshots during startup. -type TenantMandateBundlePersistence interface { - MandateBundlePersistence - LoadTenantMandateBundles(context.Context, string) ([]decision.MandateBundle, error) -} - -// MandateBundleManager validates, durably publishes, and distributes complete -// signed mandate snapshots. A higher revision replaces the prior snapshot for -// that one agent; a valid empty higher revision therefore revokes every prior -// delegation for the agent. -type MandateBundleManager struct { - mu sync.RWMutex - trust MandateBundleTrustStore - persistence MandateBundlePersistence - tenantID string - now func() time.Time - bundles map[string]decision.MandateBundle -} - -func NewMandateBundleManager(trust MandateBundleTrustStore, persistence MandateBundlePersistence, now func() time.Time) (*MandateBundleManager, error) { - return newMandateBundleManager(trust, persistence, "", now) -} - -// NewTenantMandateBundleManager scopes a manager to one authority domain when -// the underlying repository is shared by multiple tenants. -func NewTenantMandateBundleManager(trust MandateBundleTrustStore, persistence MandateBundlePersistence, tenantID string, now func() time.Time) (*MandateBundleManager, error) { - if strings.TrimSpace(tenantID) == "" { - return nil, fmt.Errorf("authority: mandate bundle tenant is required") - } - return newMandateBundleManager(trust, persistence, tenantID, now) -} - -func newMandateBundleManager(trust MandateBundleTrustStore, persistence MandateBundlePersistence, tenantID string, now func() time.Time) (*MandateBundleManager, error) { - if trust == nil || persistence == nil { - return nil, fmt.Errorf("authority: mandate trust store and persistence are required") - } - if now == nil { - now = time.Now - } - manager := &MandateBundleManager{trust: trust, persistence: persistence, tenantID: tenantID, now: now, bundles: make(map[string]decision.MandateBundle)} - var ( - stored []decision.MandateBundle - err error - ) - if tenantID != "" { - if scoped, ok := persistence.(TenantMandateBundlePersistence); ok { - stored, err = scoped.LoadTenantMandateBundles(context.Background(), tenantID) - } else { - stored, err = persistence.LoadMandateBundles(context.Background()) - } - } else { - stored, err = persistence.LoadMandateBundles(context.Background()) - } - if err != nil { - return nil, persistenceError("load mandate bundles", err) - } - for _, bundle := range stored { - if tenantID != "" && bundle.TenantID != tenantID { - continue - } - // Expiry is a normal fail-closed lifecycle state, not repository - // corruption. Verify the retained signatures at a point where the whole - // snapshot was valid, then omit it from the live distribution set. The - // durable row remains in place so persistence still enforces monotonic - // revisions when a refreshed bundle is published. - if bundle.ExpiresAt < manager.now().UTC().Unix() { - if err := manager.validateExpired(context.Background(), bundle); err != nil { - return nil, fmt.Errorf("authority: persisted expired mandate bundle: %w", err) - } - continue - } - if err := manager.validate(context.Background(), bundle); err != nil { - return nil, fmt.Errorf("authority: persisted mandate bundle: %w", err) - } - key := mandateBundleKey(bundle.TenantID, bundle.SubjectAgentID) - if existing, found := manager.bundles[key]; found { - if bundle.Revision < existing.Revision { - continue - } - if err := newerMandateBundle(existing, bundle); err != nil { - return nil, fmt.Errorf("authority: persisted mandate bundle conflict: %w", err) - } - } - manager.bundles[key] = cloneMandateBundle(bundle) - } - return manager, nil -} - -func (manager *MandateBundleManager) validateExpired(ctx context.Context, bundle decision.MandateBundle) error { - notBefore, notAfter := bundle.IssuedAt, bundle.ExpiresAt - for _, mandate := range bundle.Mandates { - if mandate.IssuedAt > notBefore { - notBefore = mandate.IssuedAt - } - if mandate.ExpiresAt < notAfter { - notAfter = mandate.ExpiresAt - } - } - if notBefore > notAfter { - return fmt.Errorf("authority: persisted mandate snapshot has no common validity window") - } - issuer, err := manager.trust.MandateKey(ctx, bundle.TenantID, bundle.KeyID) - if err != nil { - return fmt.Errorf("authority: resolve expired mandate bundle issuer: %w", err) - } - if err := bundle.Verify(ctx, issuer, manager.trust, time.Unix(notBefore, 0)); err != nil { - return err - } - return nil -} - -// Publish verifies a pre-signed, complete snapshot before it is stored. It -// never signs or edits a tenant's bundle, keeping authoring keys outside the -// service process. -func (manager *MandateBundleManager) Publish(ctx context.Context, bundle decision.MandateBundle) error { - if manager == nil { - return fmt.Errorf("authority: mandate bundle manager is not initialized") - } - if err := ctx.Err(); err != nil { - return err - } - if manager.tenantID != "" && bundle.TenantID != manager.tenantID { - return fmt.Errorf("authority: mandate bundle tenant does not match manager") - } - if err := manager.validate(ctx, bundle); err != nil { - return err - } - key := mandateBundleKey(bundle.TenantID, bundle.SubjectAgentID) - manager.mu.Lock() - defer manager.mu.Unlock() - if existing, found := manager.bundles[key]; found { - if err := newerMandateBundle(existing, bundle); err != nil { - return err - } - if bundle.Revision == existing.Revision { - return nil - } - } - if err := manager.persistence.SaveMandateBundle(ctx, bundle); err != nil { - return persistenceError("save mandate bundle", err) - } - manager.bundles[key] = cloneMandateBundle(bundle) - return nil -} - -// CurrentForAgent returns the one current snapshot for a targeted workload. -// No bundle is a fail-closed result for a mandate-requiring local enforcer; -// it does not imply that ordinary open-protocol traffic is authorized. -func (manager *MandateBundleManager) CurrentForAgent(ctx context.Context, tenantID, agentID string) (decision.MandateBundle, bool, error) { - if manager == nil { - return decision.MandateBundle{}, false, fmt.Errorf("authority: mandate bundle manager is not initialized") - } - if err := ctx.Err(); err != nil { - return decision.MandateBundle{}, false, err - } - if manager.tenantID != "" && tenantID != manager.tenantID { - return decision.MandateBundle{}, false, fmt.Errorf("authority: mandate bundle tenant does not match manager") - } - manager.mu.RLock() - bundle, found := manager.bundles[mandateBundleKey(tenantID, agentID)] - manager.mu.RUnlock() - if !found { - return decision.MandateBundle{}, false, nil - } - if err := manager.validate(ctx, bundle); err != nil { - return decision.MandateBundle{}, false, err - } - return cloneMandateBundle(bundle), true, nil -} - -// Bundles returns a stable, read-only management view for one tenant. -func (manager *MandateBundleManager) Bundles(ctx context.Context, tenantID string) ([]decision.MandateBundle, error) { - if manager == nil { - return nil, fmt.Errorf("authority: mandate bundle manager is not initialized") - } - if err := ctx.Err(); err != nil { - return nil, err - } - if manager.tenantID != "" && tenantID != manager.tenantID { - return nil, fmt.Errorf("authority: mandate bundle tenant does not match manager") - } - manager.mu.RLock() - bundles := make([]decision.MandateBundle, 0) - for _, bundle := range manager.bundles { - if bundle.TenantID == tenantID { - bundles = append(bundles, cloneMandateBundle(bundle)) - } - } - manager.mu.RUnlock() - sort.Slice(bundles, func(left, right int) bool { - return bundles[left].SubjectAgentID < bundles[right].SubjectAgentID - }) - return bundles, nil -} - -func (manager *MandateBundleManager) validate(ctx context.Context, bundle decision.MandateBundle) error { - issuer, err := manager.trust.MandateKey(ctx, bundle.TenantID, bundle.KeyID) - if err != nil { - return fmt.Errorf("authority: resolve mandate bundle issuer: %w", err) - } - if err := bundle.Verify(ctx, issuer, manager.trust, manager.now()); err != nil { - return err - } - _, revocationEpoch, err := manager.trust.MinimumState(ctx, bundle.TenantID) - if err != nil { - return err - } - if bundle.RevocationEpoch < revocationEpoch { - return fmt.Errorf("authority: mandate bundle is below tenant revocation epoch") - } - return nil -} - -func mandateBundleKey(tenantID, agentID string) string { return tenantID + "\x00" + agentID } - -func newerMandateBundle(current, candidate decision.MandateBundle) error { - if candidate.Revision < current.Revision { - return fmt.Errorf("authority: mandate bundle revision rollback") - } - if candidate.RevocationEpoch < current.RevocationEpoch { - return fmt.Errorf("authority: mandate bundle revocation epoch rollback") - } - if candidate.Revision == current.Revision { - currentHash, currentErr := current.Hash() - candidateHash, candidateErr := candidate.Hash() - if currentErr != nil || candidateErr != nil || currentHash != candidateHash || current.Signature != candidate.Signature { - return fmt.Errorf("%w: mandate bundle revision", ErrStateConflict) - } - } - return nil -} - -func cloneMandateBundle(bundle decision.MandateBundle) decision.MandateBundle { - bundle.Mandates = append([]decision.Mandate(nil), bundle.Mandates...) - for index := range bundle.Mandates { - bundle.Mandates[index].Actions = append([]string(nil), bundle.Mandates[index].Actions...) - bundle.Mandates[index].ResourcePrefixes = append([]string(nil), bundle.Mandates[index].ResourcePrefixes...) - bundle.Mandates[index].Constraints = append([]decision.Constraint(nil), bundle.Mandates[index].Constraints...) - } - return bundle -} diff --git a/internal/managedsdk/authority/policy.go b/internal/managedsdk/authority/policy.go deleted file mode 100644 index 2d7d07c3..00000000 --- a/internal/managedsdk/authority/policy.go +++ /dev/null @@ -1,403 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package authority - -import ( - "context" - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "sync" - "time" - "unicode/utf8" -) - -const ( - PolicyDomain = "pilot-policy-bundle-v1" - MaxPolicyPayloadBytes = 1 << 20 - MaxPolicyTTL = 7 * 24 * time.Hour - PolicyFailureDeny = "deny" -) - -// PolicyBundle is compiled/validated tenant policy state distributed to local -// enforcement points. Payload interpretation is engine-specific; its signed -// envelope and activation semantics are protocol-level. -type PolicyBundle struct { - Version uint16 `json:"version"` - TenantID string `json:"tenant_id"` - Revision uint64 `json:"revision"` - RevocationEpoch uint64 `json:"revocation_epoch"` - IssuedAt int64 `json:"issued_at"` - ActivatesAt int64 `json:"activates_at"` - ExpiresAt int64 `json:"expires_at"` - Engine string `json:"engine"` - EngineVersion string `json:"engine_version"` - ContentType string `json:"content_type"` - PayloadHash string `json:"payload_hash"` - Payload []byte `json:"payload"` - FailureMode string `json:"failure_mode"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func NewPolicyBundle(tenantID string, revision, revocationEpoch uint64, issuedAt, activatesAt, expiresAt time.Time, engine, engineVersion, contentType, keyID string, payload []byte) PolicyBundle { - return PolicyBundle{ - Version: SchemaVersion, TenantID: tenantID, Revision: revision, RevocationEpoch: revocationEpoch, - IssuedAt: issuedAt.Unix(), ActivatesAt: activatesAt.Unix(), ExpiresAt: expiresAt.Unix(), - Engine: engine, EngineVersion: engineVersion, ContentType: contentType, - PayloadHash: hashPolicyPayload(payload), Payload: append([]byte(nil), payload...), - FailureMode: PolicyFailureDeny, KeyID: keyID, - } -} - -func (bundle PolicyBundle) Validate() error { - if bundle.Version != SchemaVersion { - return fmt.Errorf("authority: policy bundle version %d is unsupported", bundle.Version) - } - for name, value := range map[string]string{ - "tenant_id": bundle.TenantID, "engine": bundle.Engine, "key_id": bundle.KeyID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if err := validateBoundedText("engine_version", bundle.EngineVersion, 128); err != nil { - return err - } - if err := validateBoundedText("content_type", bundle.ContentType, 128); err != nil { - return err - } - if bundle.Revision == 0 { - return fmt.Errorf("authority: policy revision must be positive") - } - if bundle.IssuedAt <= 0 || bundle.ActivatesAt < bundle.IssuedAt || bundle.ExpiresAt <= bundle.ActivatesAt { - return fmt.Errorf("authority: invalid policy validity or activation window") - } - if bundle.ExpiresAt-bundle.IssuedAt > int64(MaxPolicyTTL/time.Second) { - return fmt.Errorf("authority: policy validity exceeds %s", MaxPolicyTTL) - } - if len(bundle.Payload) == 0 || len(bundle.Payload) > MaxPolicyPayloadBytes { - return fmt.Errorf("authority: policy payload must be 1-%d bytes", MaxPolicyPayloadBytes) - } - if bundle.PayloadHash != hashPolicyPayload(bundle.Payload) { - return fmt.Errorf("authority: policy payload hash mismatch") - } - if bundle.FailureMode != PolicyFailureDeny { - return fmt.Errorf("authority: policy failure_mode must be %q in schema v1", PolicyFailureDeny) - } - return nil -} - -func (bundle PolicyBundle) Canonical() ([]byte, error) { - if err := bundle.Validate(); err != nil { - return nil, err - } - writer := canonicalWriter{} - writer.string(PolicyDomain) - writer.u16(bundle.Version) - writer.string(bundle.TenantID) - writer.u64(bundle.Revision) - writer.u64(bundle.RevocationEpoch) - writer.i64(bundle.IssuedAt) - writer.i64(bundle.ActivatesAt) - writer.i64(bundle.ExpiresAt) - writer.string(bundle.Engine) - writer.string(bundle.EngineVersion) - writer.string(bundle.ContentType) - writer.string(bundle.PayloadHash) - writer.string(string(bundle.Payload)) - writer.string(bundle.FailureMode) - writer.string(bundle.KeyID) - if writer.err != nil { - return nil, writer.err - } - return writer.Buffer.Bytes(), nil -} - -func (bundle PolicyBundle) Hash() (string, error) { - canonical, err := bundle.Canonical() - if err != nil { - return "", err - } - sum := sha256.Sum256(canonical) - return hex.EncodeToString(sum[:]), nil -} - -func (bundle *PolicyBundle) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authority: invalid policy issuer private key") - } - canonical, err := bundle.Canonical() - if err != nil { - return err - } - bundle.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (bundle PolicyBundle) Verify(publicKey ed25519.PublicKey, now time.Time) error { - canonical, err := bundle.Canonical() - if err != nil { - return err - } - if len(publicKey) != ed25519.PublicKeySize { - return fmt.Errorf("authority: invalid policy issuer public key") - } - nowUnix := now.Unix() - if bundle.IssuedAt > nowUnix+int64(MaxBundleClockSkew/time.Second) { - return fmt.Errorf("authority: policy bundle is not yet issued") - } - if bundle.ExpiresAt < nowUnix { - return fmt.Errorf("authority: policy bundle is expired") - } - signature, err := base64.StdEncoding.DecodeString(bundle.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("authority: invalid policy bundle signature") - } - return nil -} - -type PolicyValidator interface { - ValidatePolicy(ctx context.Context, engine, engineVersion, contentType string, payload []byte) error -} - -type PolicyTrustStore interface { - PolicyKey(tenantID, keyID string) (ed25519.PublicKey, error) - MinimumState(context.Context, string) (uint64, uint64, error) -} - -// PolicyManager validates, stages, and activates signed policy without ever -// falling back to an older revision. Persistence/distribution is supplied by -// the embedding control-plane process. -type PolicyManager struct { - mu sync.RWMutex - trust PolicyTrustStore - validator PolicyValidator - now func() time.Time - active map[string]PolicyBundle - staged map[string]PolicyBundle - held map[string]uint64 -} - -func NewPolicyManager(trust PolicyTrustStore, validator PolicyValidator, now func() time.Time) (*PolicyManager, error) { - if trust == nil || validator == nil { - return nil, fmt.Errorf("authority: trust store and policy validator are required") - } - if now == nil { - now = time.Now - } - return &PolicyManager{ - trust: trust, validator: validator, now: now, - active: make(map[string]PolicyBundle), staged: make(map[string]PolicyBundle), held: make(map[string]uint64), - }, nil -} - -func (manager *PolicyManager) Install(ctx context.Context, bundle PolicyBundle) error { - return manager.install(ctx, bundle, nil, false) -} - -// InstallWithCommit validates and compiles the candidate, then invokes commit -// while the manager's revision lock is held and before the candidate becomes -// visible. A failed durable commit therefore cannot advance in-memory policy. -func (manager *PolicyManager) InstallWithCommit(ctx context.Context, bundle PolicyBundle, commit func() error) error { - return manager.install(ctx, bundle, commit, false) -} - -// InstallHeldWithCommit persists and stages managed desired state without -// allowing time alone to activate it. A verified rollout activation is needed. -func (manager *PolicyManager) InstallHeldWithCommit(ctx context.Context, bundle PolicyBundle, commit func() error) error { - return manager.install(ctx, bundle, commit, true) -} - -func (manager *PolicyManager) install(ctx context.Context, bundle PolicyBundle, commit func() error, held bool) error { - if err := manager.Validate(ctx, bundle); err != nil { - return err - } - manager.mu.Lock() - defer manager.mu.Unlock() - if err := manager.rejectRollbackLocked(bundle); err != nil { - return err - } - if commit != nil { - if err := commit(); err != nil { - return fmt.Errorf("authority: durable policy commit failed: %w", err) - } - } - if held { - manager.staged[bundle.TenantID] = clonePolicyBundle(bundle) - manager.held[bundle.TenantID] = bundle.Revision - } else if bundle.ActivatesAt <= manager.now().Unix() { - manager.active[bundle.TenantID] = clonePolicyBundle(bundle) - delete(manager.staged, bundle.TenantID) - delete(manager.held, bundle.TenantID) - } else { - manager.staged[bundle.TenantID] = clonePolicyBundle(bundle) - delete(manager.held, bundle.TenantID) - } - return nil -} - -// Validate authenticates, checks state floors, and compiles a policy bundle -// without mutating active or staged state. -func (manager *PolicyManager) Validate(ctx context.Context, bundle PolicyBundle) error { - if err := ctx.Err(); err != nil { - return err - } - publicKey, err := manager.trust.PolicyKey(bundle.TenantID, bundle.KeyID) - if err != nil { - return fmt.Errorf("authority: resolve policy key: %w", err) - } - if err := bundle.Verify(publicKey, manager.now()); err != nil { - return err - } - minimumPolicy, minimumRevocation, err := manager.trust.MinimumState(ctx, bundle.TenantID) - if err != nil { - return err - } - if bundle.Revision < minimumPolicy || bundle.RevocationEpoch < minimumRevocation { - return fmt.Errorf("authority: policy bundle is below tenant state floor") - } - if err := manager.validator.ValidatePolicy(ctx, bundle.Engine, bundle.EngineVersion, bundle.ContentType, append([]byte(nil), bundle.Payload...)); err != nil { - return fmt.Errorf("authority: policy validation failed: %w", err) - } - return nil -} - -func (manager *PolicyManager) ValidateInstall(ctx context.Context, bundle PolicyBundle) error { - if err := manager.Validate(ctx, bundle); err != nil { - return err - } - manager.mu.RLock() - defer manager.mu.RUnlock() - return manager.rejectRollbackLocked(bundle) -} - -type PolicySnapshot struct { - Active *PolicyBundle - Staged *PolicyBundle - Held bool -} - -func (manager *PolicyManager) Snapshot(tenantID string) PolicySnapshot { - manager.mu.RLock() - defer manager.mu.RUnlock() - snapshot := PolicySnapshot{} - if active, exists := manager.active[tenantID]; exists { - clone := clonePolicyBundle(active) - snapshot.Active = &clone - } - if staged, exists := manager.staged[tenantID]; exists { - clone := clonePolicyBundle(staged) - snapshot.Staged = &clone - snapshot.Held = manager.held[tenantID] == staged.Revision - } - return snapshot -} - -func (manager *PolicyManager) Active(ctx context.Context, tenantID string) (PolicyBundle, error) { - if err := ctx.Err(); err != nil { - return PolicyBundle{}, err - } - manager.mu.Lock() - now := manager.now().Unix() - if staged, exists := manager.staged[tenantID]; exists && staged.ActivatesAt <= now && manager.held[tenantID] != staged.Revision { - manager.active[tenantID] = staged - delete(manager.staged, tenantID) - delete(manager.held, tenantID) - } - active, exists := manager.active[tenantID] - active = clonePolicyBundle(active) - manager.mu.Unlock() - if !exists { - return PolicyBundle{}, fmt.Errorf("authority: tenant %q has no active policy", tenantID) - } - if active.ExpiresAt < now { - return PolicyBundle{}, fmt.Errorf("authority: tenant %q active policy is expired", tenantID) - } - minimumPolicy, minimumRevocation, err := manager.trust.MinimumState(ctx, tenantID) - if err != nil { - return PolicyBundle{}, err - } - if active.Revision < minimumPolicy || active.RevocationEpoch < minimumRevocation { - return PolicyBundle{}, fmt.Errorf("authority: tenant %q active policy is stale", tenantID) - } - return active, nil -} - -// ActivateHeldWithCommit promotes a managed staged revision only after its -// signed earliest activation time and only after durable activation commit. -func (manager *PolicyManager) ActivateHeldWithCommit(ctx context.Context, tenantID string, revision uint64, commit func() error) error { - if err := ctx.Err(); err != nil { - return err - } - manager.mu.Lock() - defer manager.mu.Unlock() - staged, exists := manager.staged[tenantID] - if !exists || staged.Revision != revision || manager.held[tenantID] != revision { - return fmt.Errorf("authority: managed policy revision is not held for activation") - } - if manager.now().Unix() < staged.ActivatesAt { - return fmt.Errorf("authority: managed policy activation is too early") - } - minimumPolicy, minimumRevocation, err := manager.trust.MinimumState(ctx, tenantID) - if err != nil { - return err - } - if staged.Revision < minimumPolicy || staged.RevocationEpoch < minimumRevocation { - return fmt.Errorf("authority: managed policy is below tenant state floor") - } - if commit != nil { - if err := commit(); err != nil { - return fmt.Errorf("authority: durable policy activation commit failed: %w", err) - } - } - manager.active[tenantID] = staged - delete(manager.staged, tenantID) - delete(manager.held, tenantID) - return nil -} - -func (manager *PolicyManager) rejectRollbackLocked(candidate PolicyBundle) error { - candidateHash, _ := candidate.Hash() - for _, installed := range []PolicyBundle{manager.active[candidate.TenantID], manager.staged[candidate.TenantID]} { - if installed.Revision == 0 { - continue - } - installedHash, _ := installed.Hash() - if installed.Revision == candidate.Revision && installedHash == candidateHash && installed.Signature == candidate.Signature { - continue - } - if candidate.Revision <= installed.Revision { - return fmt.Errorf("authority: policy revision rollback") - } - if candidate.RevocationEpoch < installed.RevocationEpoch { - return fmt.Errorf("authority: policy revocation epoch rollback") - } - } - return nil -} - -func clonePolicyBundle(bundle PolicyBundle) PolicyBundle { - clone := bundle - clone.Payload = append([]byte(nil), bundle.Payload...) - return clone -} - -func hashPolicyPayload(payload []byte) string { - sum := sha256.Sum256(payload) - return hex.EncodeToString(sum[:]) -} - -func validateBoundedText(name, value string, limit int) error { - if value == "" || len(value) > limit || !utf8.ValidString(value) { - return fmt.Errorf("authority: invalid %s", name) - } - for _, character := range value { - if character < 0x20 || character == 0x7f { - return fmt.Errorf("authority: invalid %s", name) - } - } - return nil -} diff --git a/internal/managedsdk/authority/rollout.go b/internal/managedsdk/authority/rollout.go deleted file mode 100644 index 550263fc..00000000 --- a/internal/managedsdk/authority/rollout.go +++ /dev/null @@ -1,1389 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package authority - -import ( - "context" - "crypto/ed25519" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "sort" - "strings" - "sync" - "time" -) - -const ( - PolicyPublicationDomain = "pilot-policy-publication-v1" - PolicyAckDomain = "pilot-policy-ack-v1" - PolicyActivationDomain = "pilot-policy-activation-v1" - PolicyWithdrawalDomain = "pilot-policy-withdrawal-v1" - MaxRolloutTargets = 10_000 -) - -type PolicyPublication struct { - Version uint16 `json:"version"` - ID string `json:"id"` - TenantID string `json:"tenant_id"` - PolicyRevision uint64 `json:"policy_revision"` - RevocationEpoch uint64 `json:"revocation_epoch"` - PolicyHash string `json:"policy_hash"` - ExpectedAgents []string `json:"expected_agents"` - RequiredAcknowledged uint32 `json:"required_acknowledged"` - IssuedAt int64 `json:"issued_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -type PolicyAckStatus string - -const ( - PolicyAckStaged PolicyAckStatus = "staged" - PolicyAckEnforced PolicyAckStatus = "enforced" - PolicyAckRejected PolicyAckStatus = "rejected" -) - -type PolicyAcknowledgement struct { - Version uint16 `json:"version"` - ID string `json:"id"` - PublicationID string `json:"publication_id"` - PolicyHash string `json:"policy_hash"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - PolicyRevision uint64 `json:"policy_revision"` - RevocationEpoch uint64 `json:"revocation_epoch"` - Status PolicyAckStatus `json:"status"` - ObservedAt int64 `json:"observed_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -type PolicyActivation struct { - Version uint16 `json:"version"` - ID string `json:"id"` - PublicationID string `json:"publication_id"` - PublicationHash string `json:"publication_hash"` - PolicyHash string `json:"policy_hash"` - TenantID string `json:"tenant_id"` - PolicyRevision uint64 `json:"policy_revision"` - RevocationEpoch uint64 `json:"revocation_epoch"` - IssuedAt int64 `json:"issued_at"` - ActivatesAt int64 `json:"activates_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -// PolicyWithdrawal is the signed terminal state for a published candidate or -// a future activation. It never rolls back an already-active policy; that -// requires a higher signed policy revision. Agents and authority replicas can -// therefore distinguish an intentional withdrawal from transport absence. -type PolicyWithdrawal struct { - Version uint16 `json:"version"` - ID string `json:"id"` - PublicationID string `json:"publication_id"` - PublicationHash string `json:"publication_hash"` - TenantID string `json:"tenant_id"` - PolicyRevision uint64 `json:"policy_revision"` - Reason string `json:"reason"` - IssuedAt int64 `json:"issued_at"` - KeyID string `json:"key_id"` - Signature string `json:"signature"` -} - -func (publication PolicyPublication) Validate() error { - if publication.Version != SchemaVersion { - return fmt.Errorf("authority: unsupported policy publication version") - } - for name, value := range map[string]string{"id": publication.ID, "tenant_id": publication.TenantID, "key_id": publication.KeyID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if publication.PolicyRevision == 0 || !lowerHexAuthority(publication.PolicyHash, 64) || publication.IssuedAt <= 0 { - return fmt.Errorf("authority: invalid policy publication state") - } - if len(publication.ExpectedAgents) == 0 || len(publication.ExpectedAgents) > MaxRolloutTargets { - return fmt.Errorf("authority: policy publication requires 1-%d target agents", MaxRolloutTargets) - } - if publication.RequiredAcknowledged == 0 || int(publication.RequiredAcknowledged) > len(publication.ExpectedAgents) { - return fmt.Errorf("authority: invalid required acknowledgement count") - } - seen := make(map[string]struct{}, len(publication.ExpectedAgents)) - for _, agentID := range publication.ExpectedAgents { - if err := validateIdentifier("expected agent", agentID); err != nil { - return err - } - if _, exists := seen[agentID]; exists { - return fmt.Errorf("authority: duplicate expected agent %q", agentID) - } - seen[agentID] = struct{}{} - } - return nil -} - -func (publication PolicyPublication) Canonical() ([]byte, error) { - if err := publication.Validate(); err != nil { - return nil, err - } - agents := append([]string(nil), publication.ExpectedAgents...) - sort.Strings(agents) - writer := canonicalWriter{} - writer.string(PolicyPublicationDomain) - writer.u16(publication.Version) - writer.string(publication.ID) - writer.string(publication.TenantID) - writer.u64(publication.PolicyRevision) - writer.u64(publication.RevocationEpoch) - writer.string(publication.PolicyHash) - // #nosec G115 -- Validate limits expected agents below uint16 capacity. - writer.u16(uint16(len(agents))) - for _, agentID := range agents { - writer.string(agentID) - } - writer.u64(uint64(publication.RequiredAcknowledged)) - writer.i64(publication.IssuedAt) - writer.string(publication.KeyID) - if writer.err != nil { - return nil, writer.err - } - return writer.Buffer.Bytes(), nil -} - -func (publication PolicyPublication) Hash() (string, error) { - canonical, err := publication.Canonical() - if err != nil { - return "", err - } - sum := sha256.Sum256(canonical) - return hex.EncodeToString(sum[:]), nil -} - -func (publication *PolicyPublication) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authority: invalid policy publication private key") - } - canonical, err := publication.Canonical() - if err != nil { - return err - } - publication.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (publication PolicyPublication) VerifyFor(bundle PolicyBundle, publicKey ed25519.PublicKey, now time.Time) error { - canonical, err := publication.Canonical() - if err != nil { - return err - } - if publication.IssuedAt > now.Unix()+int64(MaxBundleClockSkew/time.Second) { - return fmt.Errorf("authority: policy publication is not yet valid") - } - signature, err := base64.StdEncoding.DecodeString(publication.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("authority: invalid policy publication signature") - } - bundleHash, err := bundle.Hash() - if err != nil { - return err - } - if publication.TenantID != bundle.TenantID || publication.PolicyRevision != bundle.Revision || - publication.RevocationEpoch != bundle.RevocationEpoch || publication.PolicyHash != bundleHash || - publication.KeyID != bundle.KeyID || publication.IssuedAt < bundle.IssuedAt || publication.IssuedAt > bundle.ExpiresAt { - return fmt.Errorf("authority: policy publication bundle binding mismatch") - } - return nil -} - -func NewPolicyAcknowledgement(publication PolicyPublication, agentID string, status PolicyAckStatus, observedAt time.Time, keyID string) (PolicyAcknowledgement, error) { - ack := PolicyAcknowledgement{ - Version: SchemaVersion, PublicationID: publication.ID, PolicyHash: publication.PolicyHash, - TenantID: publication.TenantID, AgentID: agentID, PolicyRevision: publication.PolicyRevision, - RevocationEpoch: publication.RevocationEpoch, Status: status, ObservedAt: observedAt.Unix(), KeyID: keyID, - } - ack.ID = acknowledgementID(ack) - if err := ack.Validate(); err != nil { - return PolicyAcknowledgement{}, err - } - return ack, nil -} - -func (ack PolicyAcknowledgement) Validate() error { - if ack.Version != SchemaVersion || !lowerHexAuthority(ack.ID, 64) || !lowerHexAuthority(ack.PolicyHash, 64) { - return fmt.Errorf("authority: invalid policy acknowledgement identity") - } - for name, value := range map[string]string{ - "publication_id": ack.PublicationID, "tenant_id": ack.TenantID, "agent_id": ack.AgentID, "key_id": ack.KeyID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if ack.PolicyRevision == 0 || ack.ObservedAt <= 0 { - return fmt.Errorf("authority: invalid policy acknowledgement state") - } - switch ack.Status { - case PolicyAckStaged, PolicyAckEnforced, PolicyAckRejected: - default: - return fmt.Errorf("authority: invalid policy acknowledgement status %q", ack.Status) - } - if ack.ID != acknowledgementID(ack) { - return fmt.Errorf("authority: noncanonical policy acknowledgement id") - } - return nil -} - -func (ack PolicyAcknowledgement) Canonical() ([]byte, error) { - if err := ack.Validate(); err != nil { - return nil, err - } - writer := canonicalWriter{} - writer.string(PolicyAckDomain) - writer.u16(ack.Version) - writer.string(ack.ID) - writer.string(ack.PublicationID) - writer.string(ack.PolicyHash) - writer.string(ack.TenantID) - writer.string(ack.AgentID) - writer.u64(ack.PolicyRevision) - writer.u64(ack.RevocationEpoch) - writer.string(string(ack.Status)) - writer.i64(ack.ObservedAt) - writer.string(ack.KeyID) - if writer.err != nil { - return nil, writer.err - } - return writer.Buffer.Bytes(), nil -} - -func (ack *PolicyAcknowledgement) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authority: invalid policy acknowledgement private key") - } - return ack.SignWith(func(message []byte) ([]byte, error) { - return ed25519.Sign(privateKey, message), nil - }) -} - -func (ack *PolicyAcknowledgement) SignWith(signer func([]byte) ([]byte, error)) error { - if signer == nil { - return fmt.Errorf("authority: policy acknowledgement signer is required") - } - canonical, err := ack.Canonical() - if err != nil { - return err - } - signature, err := signer(canonical) - if err != nil { - return fmt.Errorf("authority: sign policy acknowledgement: %w", err) - } - if len(signature) != ed25519.SignatureSize { - return fmt.Errorf("authority: policy acknowledgement signer returned invalid signature length") - } - ack.Signature = base64.StdEncoding.EncodeToString(signature) - return nil -} - -func (ack PolicyAcknowledgement) Verify(publicKey ed25519.PublicKey, now time.Time) error { - canonical, err := ack.Canonical() - if err != nil { - return err - } - if ack.ObservedAt > now.Unix()+int64(MaxBundleClockSkew/time.Second) { - return fmt.Errorf("authority: policy acknowledgement is from the future") - } - signature, err := base64.StdEncoding.DecodeString(ack.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("authority: invalid policy acknowledgement signature") - } - return nil -} - -func NewPolicyActivation(publication PolicyPublication, bundle PolicyBundle, issuedAt, activatesAt time.Time, keyID string) (PolicyActivation, error) { - publicationHash, err := publication.Hash() - if err != nil { - return PolicyActivation{}, err - } - activation := PolicyActivation{ - Version: SchemaVersion, PublicationID: publication.ID, PublicationHash: publicationHash, - PolicyHash: publication.PolicyHash, TenantID: publication.TenantID, - PolicyRevision: publication.PolicyRevision, RevocationEpoch: publication.RevocationEpoch, - IssuedAt: issuedAt.Unix(), ActivatesAt: activatesAt.Unix(), KeyID: keyID, - } - activation.ID = policyActivationID(activation) - if err := activation.Validate(); err != nil { - return PolicyActivation{}, err - } - if activation.ActivatesAt < bundle.ActivatesAt || activation.ActivatesAt >= bundle.ExpiresAt { - return PolicyActivation{}, fmt.Errorf("authority: activation time is outside policy window") - } - return activation, nil -} - -func (activation PolicyActivation) Validate() error { - if activation.Version != SchemaVersion || !lowerHexAuthority(activation.ID, 64) || - !lowerHexAuthority(activation.PublicationHash, 64) || !lowerHexAuthority(activation.PolicyHash, 64) { - return fmt.Errorf("authority: invalid policy activation identity") - } - for name, value := range map[string]string{ - "publication_id": activation.PublicationID, "tenant_id": activation.TenantID, "key_id": activation.KeyID, - } { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if activation.PolicyRevision == 0 || activation.IssuedAt <= 0 || activation.ActivatesAt < activation.IssuedAt { - return fmt.Errorf("authority: invalid policy activation state") - } - if activation.ID != policyActivationID(activation) { - return fmt.Errorf("authority: noncanonical policy activation id") - } - return nil -} - -func (activation PolicyActivation) Canonical() ([]byte, error) { - if err := activation.Validate(); err != nil { - return nil, err - } - writer := canonicalWriter{} - writer.string(PolicyActivationDomain) - writer.u16(activation.Version) - writer.string(activation.ID) - writer.string(activation.PublicationID) - writer.string(activation.PublicationHash) - writer.string(activation.PolicyHash) - writer.string(activation.TenantID) - writer.u64(activation.PolicyRevision) - writer.u64(activation.RevocationEpoch) - writer.i64(activation.IssuedAt) - writer.i64(activation.ActivatesAt) - writer.string(activation.KeyID) - if writer.err != nil { - return nil, writer.err - } - return writer.Buffer.Bytes(), nil -} - -func (activation *PolicyActivation) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authority: invalid policy activation private key") - } - canonical, err := activation.Canonical() - if err != nil { - return err - } - activation.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (activation PolicyActivation) VerifyFor(publication PolicyPublication, bundle PolicyBundle, publicKey ed25519.PublicKey, now time.Time) error { - canonical, err := activation.Canonical() - if err != nil { - return err - } - if activation.IssuedAt > now.Unix()+int64(MaxBundleClockSkew/time.Second) { - return fmt.Errorf("authority: policy activation is not yet issued") - } - signature, err := base64.StdEncoding.DecodeString(activation.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("authority: invalid policy activation signature") - } - publicationHash, _ := publication.Hash() - if activation.PublicationID != publication.ID || activation.PublicationHash != publicationHash || - activation.PolicyHash != publication.PolicyHash || activation.TenantID != publication.TenantID || - activation.PolicyRevision != publication.PolicyRevision || activation.RevocationEpoch != publication.RevocationEpoch || - activation.ActivatesAt < bundle.ActivatesAt || activation.ActivatesAt >= bundle.ExpiresAt || - activation.IssuedAt < publication.IssuedAt-int64(MaxBundleClockSkew/time.Second) { - return fmt.Errorf("authority: policy activation binding mismatch") - } - return nil -} - -func NewPolicyWithdrawal(publication PolicyPublication, reason string, issuedAt time.Time, keyID string) (PolicyWithdrawal, error) { - publicationHash, err := publication.Hash() - if err != nil { - return PolicyWithdrawal{}, err - } - withdrawal := PolicyWithdrawal{ - Version: SchemaVersion, PublicationID: publication.ID, PublicationHash: publicationHash, - TenantID: publication.TenantID, PolicyRevision: publication.PolicyRevision, - Reason: strings.TrimSpace(reason), IssuedAt: issuedAt.Unix(), KeyID: keyID, - } - withdrawal.ID = policyWithdrawalID(withdrawal) - if err := withdrawal.Validate(); err != nil { - return PolicyWithdrawal{}, err - } - return withdrawal, nil -} - -func (withdrawal PolicyWithdrawal) Validate() error { - if withdrawal.Version != SchemaVersion || !lowerHexAuthority(withdrawal.ID, 64) || !lowerHexAuthority(withdrawal.PublicationHash, 64) { - return fmt.Errorf("authority: invalid policy withdrawal identity") - } - for name, value := range map[string]string{"publication_id": withdrawal.PublicationID, "tenant_id": withdrawal.TenantID, "key_id": withdrawal.KeyID} { - if err := validateIdentifier(name, value); err != nil { - return err - } - } - if withdrawal.PolicyRevision == 0 || withdrawal.IssuedAt <= 0 || !boundedAuditText(withdrawal.Reason, 2048, false) || withdrawal.ID != policyWithdrawalID(withdrawal) { - return fmt.Errorf("authority: invalid policy withdrawal state") - } - return nil -} - -func (withdrawal PolicyWithdrawal) Canonical() ([]byte, error) { - if err := withdrawal.Validate(); err != nil { - return nil, err - } - writer := canonicalWriter{} - writer.string(PolicyWithdrawalDomain) - writer.u16(withdrawal.Version) - writer.string(withdrawal.ID) - writer.string(withdrawal.PublicationID) - writer.string(withdrawal.PublicationHash) - writer.string(withdrawal.TenantID) - writer.u64(withdrawal.PolicyRevision) - writer.string(withdrawal.Reason) - writer.i64(withdrawal.IssuedAt) - writer.string(withdrawal.KeyID) - if writer.err != nil { - return nil, writer.err - } - return writer.Buffer.Bytes(), nil -} - -func (withdrawal *PolicyWithdrawal) Sign(privateKey ed25519.PrivateKey) error { - if len(privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authority: invalid policy withdrawal private key") - } - canonical, err := withdrawal.Canonical() - if err != nil { - return err - } - withdrawal.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)) - return nil -} - -func (withdrawal PolicyWithdrawal) VerifyFor(publication PolicyPublication, publicKey ed25519.PublicKey, now time.Time) error { - canonical, err := withdrawal.Canonical() - if err != nil { - return err - } - if withdrawal.IssuedAt > now.Unix()+int64(MaxBundleClockSkew/time.Second) { - return fmt.Errorf("authority: policy withdrawal is not yet issued") - } - signature, err := base64.StdEncoding.DecodeString(withdrawal.Signature) - if err != nil || len(signature) != ed25519.SignatureSize || len(publicKey) != ed25519.PublicKeySize || !ed25519.Verify(publicKey, canonical, signature) { - return fmt.Errorf("authority: invalid policy withdrawal signature") - } - publicationHash, _ := publication.Hash() - if withdrawal.PublicationID != publication.ID || withdrawal.PublicationHash != publicationHash || withdrawal.TenantID != publication.TenantID || - withdrawal.PolicyRevision != publication.PolicyRevision || withdrawal.IssuedAt < publication.IssuedAt-int64(MaxBundleClockSkew/time.Second) { - return fmt.Errorf("authority: policy withdrawal binding mismatch") - } - return nil -} - -type RolloutStatus struct { - Publication PolicyPublication `json:"publication"` - Staged int `json:"staged"` - Enforced int `json:"enforced"` - Rejected int `json:"rejected"` - Pending []string `json:"pending"` - Ready bool `json:"ready"` - Activation *PolicyActivation `json:"activation,omitempty"` - Withdrawal *PolicyWithdrawal `json:"withdrawal,omitempty"` -} - -type RolloutTrustStore interface { - ManagesTenant(string) bool - ManagedTenants() []string - PolicyKey(tenantID, keyID string) (ed25519.PublicKey, error) - PolicyKeyAt(tenantID, keyID string, at time.Time) (ed25519.PublicKey, error) - IntentKey(context.Context, string, string, string) (ed25519.PublicKey, error) - IntentKeyAt(tenantID, agentID, keyID string, at time.Time) (ed25519.PublicKey, error) - MinimumState(context.Context, string) (uint64, uint64, error) -} - -type RolloutManager struct { - mu sync.RWMutex - refreshMu sync.Mutex - trust RolloutTrustStore - policies *PolicyManager - repository RolloutPersistence - now func() time.Time - publications map[string]PolicyPublication - acks map[string]map[string]PolicyAcknowledgement - bundles map[string]PolicyBundle - activations map[string]PolicyActivation - withdrawals map[string]PolicyWithdrawal - generations map[string]uint64 -} - -func NewRolloutManager(trust RolloutTrustStore, policies *PolicyManager, repository RolloutPersistence, now func() time.Time) (*RolloutManager, error) { - if trust == nil || policies == nil || repository == nil { - return nil, fmt.Errorf("authority: trust, policy manager, and rollout repository are required") - } - if now == nil { - now = time.Now - } - manager := &RolloutManager{ - trust: trust, policies: policies, repository: repository, now: now, - publications: make(map[string]PolicyPublication), acks: make(map[string]map[string]PolicyAcknowledgement), - bundles: make(map[string]PolicyBundle), activations: make(map[string]PolicyActivation), withdrawals: make(map[string]PolicyWithdrawal), - generations: make(map[string]uint64), - } - stored, err := loadManagedRollouts(context.Background(), repository, trust.ManagedTenants()) - if err != nil { - return nil, err - } - for _, item := range stored { - if !manager.trust.ManagesTenant(item.Publication.TenantID) { - continue - } - if err := manager.installLoaded(item); err != nil { - return nil, err - } - } - return manager, nil -} - -func (manager *RolloutManager) Publish(ctx context.Context, publication PolicyPublication, bundle PolicyBundle) error { - if err := manager.ValidateCandidate(ctx, publication, bundle); err != nil { - return err - } - manager.mu.Lock() - defer manager.mu.Unlock() - for _, existing := range manager.publications { - if existing.TenantID == publication.TenantID && existing.PolicyRevision == publication.PolicyRevision && existing.ID != publication.ID { - return fmt.Errorf("authority: policy revision already has another publication") - } - } - if existing, exists := manager.publications[publication.ID]; exists { - existingHash, _ := existing.Hash() - candidateHash, _ := publication.Hash() - if existingHash == candidateHash && existing.Signature == publication.Signature { - return nil - } - return fmt.Errorf("authority: conflicting policy publication id") - } - if err := manager.policies.InstallHeldWithCommit(ctx, bundle, func() error { - if err := manager.repository.SavePolicyPublication(ctx, publication, bundle); err != nil { - return persistenceError("save policy publication", err) - } - return nil - }); err != nil { - return err - } - manager.publications[publication.ID] = clonePublication(publication) - manager.bundles[publication.ID] = clonePolicyBundle(bundle) - manager.acks[publication.ID] = make(map[string]PolicyAcknowledgement) - return nil -} - -func (manager *RolloutManager) ValidateCandidate(ctx context.Context, publication PolicyPublication, bundle PolicyBundle) error { - if err := manager.Refresh(ctx); err != nil { - return err - } - publicKey, err := manager.trust.PolicyKey(publication.TenantID, publication.KeyID) - if err != nil { - return err - } - if err := publication.VerifyFor(bundle, publicKey, manager.now()); err != nil { - return err - } - if err := manager.policies.ValidateInstall(ctx, bundle); err != nil { - return err - } - manager.mu.RLock() - defer manager.mu.RUnlock() - for _, existing := range manager.publications { - if existing.TenantID == publication.TenantID && existing.PolicyRevision == publication.PolicyRevision && existing.ID != publication.ID { - return fmt.Errorf("authority: policy revision already has another publication") - } - } - if existing, exists := manager.publications[publication.ID]; exists { - existingHash, _ := existing.Hash() - candidateHash, _ := publication.Hash() - if existingHash == candidateHash && existing.Signature == publication.Signature { - return nil - } - return fmt.Errorf("authority: conflicting policy publication id") - } - return nil -} - -func (manager *RolloutManager) CurrentPolicy(ctx context.Context, tenantID string) (PolicyBundle, error) { - if err := manager.Refresh(ctx); err != nil { - return PolicyBundle{}, err - } - return manager.policies.Active(ctx, tenantID) -} - -// CandidateForAgent returns the newest unactivated policy publication that -// explicitly targets agentID. The bundle remains held at the authority until -// the signed acknowledgement threshold and a signed activation are present; -// callers must not treat this response as active authority. -func (manager *RolloutManager) CandidateForAgent(ctx context.Context, tenantID, agentID string) (PolicyPublication, PolicyBundle, bool, error) { - if err := manager.Refresh(ctx); err != nil { - return PolicyPublication{}, PolicyBundle{}, false, err - } - manager.mu.RLock() - defer manager.mu.RUnlock() - var ( - candidate PolicyPublication - found bool - ) - for publicationID, publication := range manager.publications { - if publication.TenantID != tenantID || !publicationTargets(publication, agentID) { - continue - } - if _, withdrawn := manager.withdrawals[publicationID]; withdrawn { - continue - } - if activation, activated := manager.activations[publicationID]; activated && activation.ActivatesAt <= manager.now().Unix() { - continue - } - if !found || publication.PolicyRevision > candidate.PolicyRevision || (publication.PolicyRevision == candidate.PolicyRevision && publication.ID > candidate.ID) { - candidate = publication - found = true - } - } - if !found { - return PolicyPublication{}, PolicyBundle{}, false, nil - } - bundle, exists := manager.bundles[candidate.ID] - if !exists { - return PolicyPublication{}, PolicyBundle{}, false, fmt.Errorf("authority: rollout candidate bundle is missing") - } - return clonePublication(candidate), clonePolicyBundle(bundle), true, nil -} - -// BundleForPublication returns one immutable historical rollout artifact for -// review or a monotonic superseding rollback. It never changes active state. -func (manager *RolloutManager) BundleForPublication(ctx context.Context, tenantID, publicationID string) (PolicyPublication, PolicyBundle, bool, error) { - if err := manager.Refresh(ctx); err != nil { - return PolicyPublication{}, PolicyBundle{}, false, err - } - manager.mu.RLock() - defer manager.mu.RUnlock() - publication, exists := manager.publications[publicationID] - if !exists || publication.TenantID != tenantID { - return PolicyPublication{}, PolicyBundle{}, false, nil - } - bundle, exists := manager.bundles[publicationID] - if !exists { - return PolicyPublication{}, PolicyBundle{}, false, fmt.Errorf("authority: rollout bundle is missing") - } - return clonePublication(publication), clonePolicyBundle(bundle), true, nil -} - -// ActiveForAgent returns the active policy together with the publication and -// signed activation that promoted it, but only when the publication explicitly -// targets agentID. A bare signed bundle is intentionally insufficient proof of -// managed activation. -func (manager *RolloutManager) ActiveForAgent(ctx context.Context, tenantID, agentID string) (PolicyPublication, PolicyBundle, PolicyActivation, bool, error) { - if err := manager.Refresh(ctx); err != nil { - return PolicyPublication{}, PolicyBundle{}, PolicyActivation{}, false, err - } - active, err := manager.policies.Active(ctx, tenantID) - if err != nil { - return PolicyPublication{}, PolicyBundle{}, PolicyActivation{}, false, err - } - activeHash, err := active.Hash() - if err != nil { - return PolicyPublication{}, PolicyBundle{}, PolicyActivation{}, false, err - } - manager.mu.RLock() - defer manager.mu.RUnlock() - for publicationID, publication := range manager.publications { - if publication.TenantID != tenantID || !publicationTargets(publication, agentID) || publication.PolicyHash != activeHash || publication.PolicyRevision != active.Revision || publication.RevocationEpoch != active.RevocationEpoch { - continue - } - activation, activated := manager.activations[publicationID] - if !activated { - continue - } - return clonePublication(publication), clonePolicyBundle(active), activation, true, nil - } - return PolicyPublication{}, PolicyBundle{}, PolicyActivation{}, false, nil -} - -func (manager *RolloutManager) Acknowledge(ctx context.Context, ack PolicyAcknowledgement) error { - if err := ctx.Err(); err != nil { - return err - } - if err := manager.Refresh(ctx); err != nil { - return err - } - manager.mu.Lock() - defer manager.mu.Unlock() - return manager.acknowledgeLocked(ctx, ack, true) -} - -func (manager *RolloutManager) acknowledgeLocked(ctx context.Context, ack PolicyAcknowledgement, persist bool) error { - publication, exists := manager.publications[ack.PublicationID] - if !exists { - return ErrUnknownPolicyPublication - } - if _, withdrawn := manager.withdrawals[ack.PublicationID]; withdrawn && persist { - return fmt.Errorf("authority: policy publication is withdrawn") - } - if !publicationTargets(publication, ack.AgentID) || ack.PolicyHash != publication.PolicyHash || - ack.TenantID != publication.TenantID || ack.PolicyRevision != publication.PolicyRevision || - ack.RevocationEpoch != publication.RevocationEpoch { - return fmt.Errorf("authority: policy acknowledgement publication binding mismatch") - } - var publicKey ed25519.PublicKey - var err error - if persist { - publicKey, err = manager.trust.IntentKey(ctx, ack.TenantID, ack.AgentID, ack.KeyID) - } else { - publicKey, err = manager.trust.IntentKeyAt(ack.TenantID, ack.AgentID, ack.KeyID, time.Unix(ack.ObservedAt, 0)) - } - if err != nil { - return err - } - if err := ack.Verify(publicKey, manager.now()); err != nil { - return err - } - if ack.ObservedAt < publication.IssuedAt-int64(MaxBundleClockSkew/time.Second) { - return fmt.Errorf("authority: policy acknowledgement predates publication") - } - activation, activated := manager.activations[publication.ID] - if existing, exists := manager.acks[publication.ID][ack.AgentID]; exists { - if existing.ID == ack.ID && existing.Signature == ack.Signature { - return nil - } - if activated { - if !postActivationEnforcementAllowed(&existing, ack, activation, manager.now()) { - return fmt.Errorf("authority: only staged-to-enforced acknowledgement is allowed after activation") - } - } else if !ackTransitionAllowed(existing.Status, ack.Status) || ack.ObservedAt < existing.ObservedAt { - return fmt.Errorf("authority: policy acknowledgement status rollback") - } - } else if activated && !postActivationEnforcementAllowed(nil, ack, activation, manager.now()) { - return fmt.Errorf("authority: only enforced acknowledgement is allowed after activation") - } - if persist { - if err := manager.repository.AppendPolicyAcknowledgement(ctx, ack); err != nil { - return persistenceError("save policy acknowledgement", err) - } - } - manager.acks[publication.ID][ack.AgentID] = ack - return nil -} - -func (manager *RolloutManager) Status(publicationID string) (RolloutStatus, error) { - if err := manager.Refresh(context.Background()); err != nil { - return RolloutStatus{}, err - } - manager.mu.RLock() - defer manager.mu.RUnlock() - publication, exists := manager.publications[publicationID] - if !exists { - return RolloutStatus{}, ErrUnknownPolicyPublication - } - return manager.statusLocked(publication), nil -} - -// Statuses returns every policy rollout for one managed tenant, newest policy -// revision first. It is a read-model for an operator interface: the returned -// records contain immutable signed publication data and derived acknowledgement -// progress, but grant no ability to change policy or activation state. -func (manager *RolloutManager) Statuses(ctx context.Context, tenantID string) ([]RolloutStatus, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if !manager.trust.ManagesTenant(tenantID) { - return nil, fmt.Errorf("authority: tenant %q is not managed", tenantID) - } - if err := manager.Refresh(ctx); err != nil { - return nil, err - } - manager.mu.RLock() - defer manager.mu.RUnlock() - statuses := make([]RolloutStatus, 0) - for _, publication := range manager.publications { - if publication.TenantID == tenantID { - statuses = append(statuses, manager.statusLocked(publication)) - } - } - sort.Slice(statuses, func(left, right int) bool { - if statuses[left].Publication.PolicyRevision != statuses[right].Publication.PolicyRevision { - return statuses[left].Publication.PolicyRevision > statuses[right].Publication.PolicyRevision - } - return statuses[left].Publication.ID < statuses[right].Publication.ID - }) - return statuses, nil -} - -func (manager *RolloutManager) statusLocked(publication PolicyPublication) RolloutStatus { - status := RolloutStatus{Publication: clonePublication(publication)} - for _, agentID := range publication.ExpectedAgents { - ack, acknowledged := manager.acks[publication.ID][agentID] - if !acknowledged { - status.Pending = append(status.Pending, agentID) - continue - } - switch ack.Status { - case PolicyAckStaged: - status.Staged++ - case PolicyAckEnforced: - status.Enforced++ - case PolicyAckRejected: - status.Rejected++ - } - } - sort.Strings(status.Pending) - status.Ready = status.Rejected == 0 && status.Staged+status.Enforced >= int(publication.RequiredAcknowledged) - if activation, exists := manager.activations[publication.ID]; exists { - clone := activation - status.Activation = &clone - } - if withdrawal, exists := manager.withdrawals[publication.ID]; exists { - clone := withdrawal - status.Withdrawal = &clone - status.Ready = false - } - return status -} - -// Withdraw durably terminates distribution of a candidate or cancels a future -// activation. It intentionally refuses to alter an activation whose time has -// arrived; an active policy is superseded only through a higher revision. -func (manager *RolloutManager) Withdraw(ctx context.Context, withdrawal PolicyWithdrawal) error { - if err := ctx.Err(); err != nil { - return err - } - persistence, supported := manager.repository.(RolloutWithdrawalPersistence) - if !supported { - return fmt.Errorf("authority: rollout withdrawal persistence is unavailable") - } - if err := manager.Refresh(ctx); err != nil { - return err - } - manager.mu.Lock() - defer manager.mu.Unlock() - publication, exists := manager.publications[withdrawal.PublicationID] - if !exists { - return ErrUnknownPolicyPublication - } - publicKey, err := manager.trust.PolicyKey(withdrawal.TenantID, withdrawal.KeyID) - if err != nil { - return err - } - if err := withdrawal.VerifyFor(publication, publicKey, manager.now()); err != nil { - return err - } - if existing, exists := manager.withdrawals[publication.ID]; exists { - if existing.ID == withdrawal.ID && existing.Signature == withdrawal.Signature { - return nil - } - return fmt.Errorf("authority: conflicting policy withdrawal") - } - if activation, exists := manager.activations[publication.ID]; exists && activation.ActivatesAt <= manager.now().Unix() { - return fmt.Errorf("authority: active policy cannot be withdrawn; publish a higher rollback revision") - } - active, activeErr := manager.policies.Active(ctx, publication.TenantID) - if activeErr == nil && active.Revision >= publication.PolicyRevision { - return fmt.Errorf("authority: active policy cannot be withdrawn; publish a higher rollback revision") - } - if err := persistence.SavePolicyWithdrawal(ctx, withdrawal); err != nil { - return persistenceError("save policy withdrawal", err) - } - manager.withdrawals[publication.ID] = withdrawal - return nil -} - -func (manager *RolloutManager) Activate(ctx context.Context, activation PolicyActivation) error { - if err := ctx.Err(); err != nil { - return err - } - if err := manager.Refresh(ctx); err != nil { - return err - } - manager.mu.Lock() - defer manager.mu.Unlock() - return manager.activateLocked(ctx, activation, true) -} - -// ScheduleActivation durably records a signed future activation. Refresh -// promotes it once its signed time arrives; until then agents can still fetch -// and acknowledge the held candidate. -func (manager *RolloutManager) ScheduleActivation(ctx context.Context, activation PolicyActivation) error { - if err := ctx.Err(); err != nil { - return err - } - if err := manager.Refresh(ctx); err != nil { - return err - } - manager.mu.Lock() - defer manager.mu.Unlock() - if activation.ActivatesAt <= manager.now().Unix() { - return fmt.Errorf("authority: scheduled policy activation must be in the future") - } - publication, exists := manager.publications[activation.PublicationID] - if !exists { - return ErrUnknownPolicyPublication - } - if _, withdrawn := manager.withdrawals[publication.ID]; withdrawn { - return fmt.Errorf("authority: withdrawn policy publication cannot be activated") - } - bundle := manager.bundles[publication.ID] - publicKey, err := manager.trust.PolicyKey(activation.TenantID, activation.KeyID) - if err != nil { - return err - } - if err := activation.VerifyFor(publication, bundle, publicKey, manager.now()); err != nil { - return err - } - if !manager.statusLocked(publication).Ready { - return fmt.Errorf("authority: policy rollout acknowledgement threshold is not ready") - } - if existing, exists := manager.activations[publication.ID]; exists { - if existing.ID == activation.ID && existing.Signature == activation.Signature { - return nil - } - return fmt.Errorf("authority: conflicting policy activation") - } - if err := manager.repository.SavePolicyActivation(ctx, activation); err != nil { - return persistenceError("save policy activation", err) - } - manager.activations[publication.ID] = activation - return nil -} - -func (manager *RolloutManager) activateLocked(ctx context.Context, activation PolicyActivation, persist bool) error { - publication, exists := manager.publications[activation.PublicationID] - if !exists { - return ErrUnknownPolicyPublication - } - if _, withdrawn := manager.withdrawals[publication.ID]; withdrawn { - return fmt.Errorf("authority: withdrawn policy publication cannot be activated") - } - bundle := manager.bundles[publication.ID] - var publicKey ed25519.PublicKey - var err error - if persist { - publicKey, err = manager.trust.PolicyKey(activation.TenantID, activation.KeyID) - } else { - publicKey, err = manager.trust.PolicyKeyAt(activation.TenantID, activation.KeyID, time.Unix(activation.IssuedAt, 0)) - } - if err != nil { - return err - } - if err := activation.VerifyFor(publication, bundle, publicKey, manager.now()); err != nil { - return err - } - if manager.now().Unix() < activation.ActivatesAt { - return fmt.Errorf("authority: policy activation time has not arrived") - } - status := manager.statusLocked(publication) - if !status.Ready { - return fmt.Errorf("authority: policy rollout acknowledgement threshold is not ready") - } - if existing, exists := manager.activations[publication.ID]; exists { - if existing.ID == activation.ID && existing.Signature == activation.Signature { - return nil - } - return fmt.Errorf("authority: conflicting policy activation") - } - commit := func() error { return nil } - if persist { - commit = func() error { - if err := manager.repository.SavePolicyActivation(ctx, activation); err != nil { - return persistenceError("save policy activation", err) - } - return nil - } - } - if err := manager.policies.ActivateHeldWithCommit(ctx, publication.TenantID, publication.PolicyRevision, commit); err != nil { - return err - } - manager.activations[publication.ID] = activation - return nil -} - -func (manager *RolloutManager) installLoaded(item StoredRollout) error { - if existing, exists := manager.publications[item.Publication.ID]; exists { - existingHash, _ := existing.Hash() - loadedHash, _ := item.Publication.Hash() - existingBundleHash, _ := manager.bundles[item.Publication.ID].Hash() - loadedBundleHash, _ := item.Bundle.Hash() - if existingHash != loadedHash || existing.Signature != item.Publication.Signature || - existingBundleHash != loadedBundleHash || manager.bundles[item.Publication.ID].Signature != item.Bundle.Signature { - return fmt.Errorf("authority: conflicting durable rollout state") - } - for _, ack := range item.Acknowledgements { - if current, acknowledged := manager.acks[item.Publication.ID][ack.AgentID]; acknowledged { - if ack.ObservedAt < current.ObservedAt { - continue - } - if ack.ObservedAt == current.ObservedAt { - if ack.ID == current.ID && ack.Signature == current.Signature { - continue - } - return fmt.Errorf("authority: conflicting durable acknowledgement state") - } - } - if err := manager.acknowledgeLocked(context.Background(), ack, false); err != nil { - return err - } - } - if err := manager.installWithdrawalLocked(item); err != nil { - return err - } - if item.Activation != nil { - if existingActivation, activated := manager.activations[item.Publication.ID]; activated { - if existingActivation.ID != item.Activation.ID || existingActivation.Signature != item.Activation.Signature { - return fmt.Errorf("authority: conflicting durable activation state") - } - return nil - } - if item.Withdrawal != nil { - return manager.installWithdrawnActivationLocked(item) - } - if item.Activation.ActivatesAt > manager.now().Unix() { - return manager.installScheduledActivationLocked(item) - } - return manager.activateLocked(context.Background(), *item.Activation, false) - } - return nil - } - publicKey, err := manager.trust.PolicyKeyAt(item.Publication.TenantID, item.Publication.KeyID, time.Unix(item.Publication.IssuedAt, 0)) - if err != nil { - return err - } - if err := item.Publication.VerifyFor(item.Bundle, publicKey, manager.now()); err != nil { - return err - } - snapshot := manager.policies.Snapshot(item.Bundle.TenantID) - latestRevision := uint64(0) - if snapshot.Active != nil { - latestRevision = snapshot.Active.Revision - } - if snapshot.Staged != nil && snapshot.Staged.Revision > latestRevision { - latestRevision = snapshot.Staged.Revision - } - minimumPolicy, minimumRevocation, err := manager.trust.MinimumState(context.Background(), item.Bundle.TenantID) - if err != nil { - return err - } - loadedAsCurrent := item.Bundle.Revision >= minimumPolicy && item.Bundle.RevocationEpoch >= minimumRevocation && item.Bundle.Revision >= latestRevision - if loadedAsCurrent { - if err := manager.policies.InstallHeldWithCommit(context.Background(), item.Bundle, nil); err != nil { - return err - } - } - manager.publications[item.Publication.ID] = clonePublication(item.Publication) - manager.bundles[item.Publication.ID] = clonePolicyBundle(item.Bundle) - manager.acks[item.Publication.ID] = make(map[string]PolicyAcknowledgement) - for _, ack := range item.Acknowledgements { - if err := manager.acknowledgeLocked(context.Background(), ack, false); err != nil { - return err - } - } - if err := manager.installWithdrawalLocked(item); err != nil { - return err - } - if item.Activation != nil { - if item.Withdrawal != nil { - return manager.installWithdrawnActivationLocked(item) - } - if loadedAsCurrent { - if item.Activation.ActivatesAt > manager.now().Unix() { - if err := manager.installScheduledActivationLocked(item); err != nil { - return err - } - } else if err := manager.activateLocked(context.Background(), *item.Activation, false); err != nil { - return err - } - } else { - publicKey, err := manager.trust.PolicyKeyAt(item.Activation.TenantID, item.Activation.KeyID, time.Unix(item.Activation.IssuedAt, 0)) - if err != nil { - return err - } - if err := item.Activation.VerifyFor(item.Publication, item.Bundle, publicKey, manager.now()); err != nil { - return err - } - if !manager.statusLocked(item.Publication).Ready { - return fmt.Errorf("authority: historical activation lacks acknowledgement threshold") - } - manager.activations[item.Publication.ID] = *item.Activation - } - } - return nil -} - -func (manager *RolloutManager) installWithdrawalLocked(item StoredRollout) error { - if item.Withdrawal == nil { - return nil - } - if existing, found := manager.withdrawals[item.Publication.ID]; found { - if existing.ID == item.Withdrawal.ID && existing.Signature == item.Withdrawal.Signature { - return nil - } - return fmt.Errorf("authority: conflicting durable withdrawal state") - } - publicKey, err := manager.trust.PolicyKeyAt(item.Withdrawal.TenantID, item.Withdrawal.KeyID, time.Unix(item.Withdrawal.IssuedAt, 0)) - if err != nil { - return err - } - if err := item.Withdrawal.VerifyFor(item.Publication, publicKey, manager.now()); err != nil { - return err - } - if item.Activation != nil && item.Activation.ActivatesAt <= item.Withdrawal.IssuedAt { - return fmt.Errorf("authority: durable withdrawal follows an effective activation") - } - manager.withdrawals[item.Publication.ID] = *item.Withdrawal - return nil -} - -func (manager *RolloutManager) installWithdrawnActivationLocked(item StoredRollout) error { - if item.Activation == nil || item.Withdrawal == nil { - return nil - } - if existing, found := manager.activations[item.Publication.ID]; found { - if existing.ID == item.Activation.ID && existing.Signature == item.Activation.Signature { - return nil - } - return fmt.Errorf("authority: conflicting durable activation state") - } - publicKey, err := manager.trust.PolicyKeyAt(item.Activation.TenantID, item.Activation.KeyID, time.Unix(item.Activation.IssuedAt, 0)) - if err != nil { - return err - } - if err := item.Activation.VerifyFor(item.Publication, item.Bundle, publicKey, manager.now()); err != nil { - return err - } - if !manager.rolloutAcknowledgementReadyLocked(item.Publication) { - return fmt.Errorf("authority: withdrawn activation lacks acknowledgement threshold") - } - manager.activations[item.Publication.ID] = *item.Activation - return nil -} - -func (manager *RolloutManager) rolloutAcknowledgementReadyLocked(publication PolicyPublication) bool { - ready := 0 - for _, agentID := range publication.ExpectedAgents { - ack, found := manager.acks[publication.ID][agentID] - if !found { - continue - } - if ack.Status == PolicyAckRejected { - return false - } - if ack.Status == PolicyAckStaged || ack.Status == PolicyAckEnforced { - ready++ - } - } - return ready >= int(publication.RequiredAcknowledged) -} - -func (manager *RolloutManager) installScheduledActivationLocked(item StoredRollout) error { - if item.Activation == nil { - return nil - } - publicKey, err := manager.trust.PolicyKeyAt(item.Activation.TenantID, item.Activation.KeyID, time.Unix(item.Activation.IssuedAt, 0)) - if err != nil { - return err - } - if err := item.Activation.VerifyFor(item.Publication, item.Bundle, publicKey, manager.now()); err != nil { - return err - } - if !manager.statusLocked(item.Publication).Ready { - return fmt.Errorf("authority: scheduled activation lacks acknowledgement threshold") - } - manager.activations[item.Publication.ID] = *item.Activation - return nil -} - -// Refresh reconciles durable rollout state into this replica. Replaying exact -// records is idempotent; any conflicting immutable record fails closed. -func (manager *RolloutManager) Refresh(ctx context.Context) error { - if err := ctx.Err(); err != nil { - return err - } - manager.refreshMu.Lock() - defer manager.refreshMu.Unlock() - tenantIDs := manager.trust.ManagedTenants() - loadTenants := tenantIDs - observedGenerations := make(map[string]uint64) - if versioned, ok := manager.repository.(RolloutGenerationPersistence); ok { - loadTenants = make([]string, 0, len(tenantIDs)) - manager.mu.RLock() - currentGenerations := make(map[string]uint64, len(manager.generations)) - for tenantID, generation := range manager.generations { - currentGenerations[tenantID] = generation - } - manager.mu.RUnlock() - for _, tenantID := range tenantIDs { - generation, err := versioned.RolloutGeneration(ctx, tenantID) - if err != nil { - return persistenceError("load rollout generation", err) - } - observedGenerations[tenantID] = generation - if generation != currentGenerations[tenantID] { - loadTenants = append(loadTenants, tenantID) - } - } - } - stored, err := loadManagedRollouts(ctx, manager.repository, loadTenants) - if err != nil { - return persistenceError("load rollout state", err) - } - manager.mu.Lock() - defer manager.mu.Unlock() - for _, item := range stored { - if !manager.trust.ManagesTenant(item.Publication.TenantID) { - continue - } - if err := manager.installLoaded(item); err != nil { - return err - } - } - for tenantID, generation := range observedGenerations { - manager.generations[tenantID] = generation - } - return manager.promoteDueActivationsLocked(ctx) -} - -func (manager *RolloutManager) promoteDueActivationsLocked(ctx context.Context) error { - now := manager.now().Unix() - publicationIDs := make([]string, 0, len(manager.activations)) - for publicationID, activation := range manager.activations { - if _, withdrawn := manager.withdrawals[publicationID]; !withdrawn && activation.ActivatesAt > 0 && activation.ActivatesAt <= now && activation.Validate() == nil { - publicationIDs = append(publicationIDs, publicationID) - } - } - sort.Slice(publicationIDs, func(i, j int) bool { - return manager.publications[publicationIDs[i]].PolicyRevision < manager.publications[publicationIDs[j]].PolicyRevision - }) - for _, publicationID := range publicationIDs { - publication := manager.publications[publicationID] - active, err := manager.policies.Active(ctx, publication.TenantID) - if err == nil && active.Revision >= publication.PolicyRevision { - continue - } - snapshot := manager.policies.Snapshot(publication.TenantID) - if snapshot.Staged == nil || snapshot.Staged.Revision != publication.PolicyRevision || !snapshot.Held { - continue - } - if err := manager.policies.ActivateHeldWithCommit(ctx, publication.TenantID, publication.PolicyRevision, nil); err != nil { - return err - } - } - return nil -} - -func loadManagedRollouts(ctx context.Context, repository RolloutPersistence, tenantIDs []string) ([]StoredRollout, error) { - if scoped, ok := repository.(TenantRolloutPersistence); ok { - var stored []StoredRollout - for _, tenantID := range tenantIDs { - rollouts, err := scoped.LoadTenantRollouts(ctx, tenantID) - if err != nil { - return nil, err - } - stored = append(stored, rollouts...) - } - return stored, nil - } - return repository.LoadRollouts(ctx) -} - -func acknowledgementID(ack PolicyAcknowledgement) string { - writer := canonicalWriter{} - writer.string(PolicyAckDomain + "/id") - writer.string(ack.PublicationID) - writer.string(ack.PolicyHash) - writer.string(ack.TenantID) - writer.string(ack.AgentID) - writer.u64(ack.PolicyRevision) - writer.u64(ack.RevocationEpoch) - writer.string(string(ack.Status)) - writer.i64(ack.ObservedAt) - writer.string(ack.KeyID) - sum := sha256.Sum256(writer.Buffer.Bytes()) - return hex.EncodeToString(sum[:]) -} - -func policyActivationID(activation PolicyActivation) string { - writer := canonicalWriter{} - writer.string(PolicyActivationDomain + "/id") - writer.string(activation.PublicationID) - writer.string(activation.PublicationHash) - writer.string(activation.PolicyHash) - writer.string(activation.TenantID) - writer.u64(activation.PolicyRevision) - writer.u64(activation.RevocationEpoch) - writer.i64(activation.IssuedAt) - writer.i64(activation.ActivatesAt) - writer.string(activation.KeyID) - sum := sha256.Sum256(writer.Buffer.Bytes()) - return hex.EncodeToString(sum[:]) -} - -func policyWithdrawalID(withdrawal PolicyWithdrawal) string { - writer := canonicalWriter{} - writer.string(PolicyWithdrawalDomain + "/id") - writer.string(withdrawal.PublicationID) - writer.string(withdrawal.PublicationHash) - writer.string(withdrawal.TenantID) - writer.u64(withdrawal.PolicyRevision) - writer.string(withdrawal.Reason) - writer.i64(withdrawal.IssuedAt) - writer.string(withdrawal.KeyID) - sum := sha256.Sum256(writer.Buffer.Bytes()) - return hex.EncodeToString(sum[:]) -} - -func publicationTargets(publication PolicyPublication, agentID string) bool { - for _, expected := range publication.ExpectedAgents { - if expected == agentID { - return true - } - } - return false -} - -func ackTransitionAllowed(from, to PolicyAckStatus) bool { - if from == PolicyAckRejected || from == PolicyAckEnforced { - return false - } - // A restarted node may have lost its local delivery cache and reassert the - // same signed staged state with a newer observation time. Treat that as a - // liveness refresh while keeping enforced/rejected terminal and continuing - // to reject older observations. - return from == PolicyAckStaged && (to == PolicyAckStaged || to == PolicyAckEnforced || to == PolicyAckRejected) -} - -// postActivationEnforcementAllowed keeps activation immutable while accepting -// the one operational fact that can only exist afterwards: a target node has -// durably installed the active policy. The transition is terminal and cannot -// weaken, reject, or restage an already activated publication. -func postActivationEnforcementAllowed(existing *PolicyAcknowledgement, next PolicyAcknowledgement, activation PolicyActivation, now time.Time) bool { - if now.Unix() < activation.ActivatesAt || next.Status != PolicyAckEnforced || next.ObservedAt < activation.ActivatesAt { - return false - } - return existing == nil || existing.Status == PolicyAckStaged && next.ObservedAt >= existing.ObservedAt -} - -func clonePublication(publication PolicyPublication) PolicyPublication { - clone := publication - clone.ExpectedAgents = append([]string(nil), publication.ExpectedAgents...) - return clone -} - -func lowerHexAuthority(value string, length int) bool { - if len(value) != length { - return false - } - for _, character := range value { - if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f')) { - return false - } - } - return true -} diff --git a/internal/managedsdk/authority/store.go b/internal/managedsdk/authority/store.go deleted file mode 100644 index a0120614..00000000 --- a/internal/managedsdk/authority/store.go +++ /dev/null @@ -1,342 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package authority - -import ( - "context" - "crypto/ed25519" - "fmt" - "sort" - "sync" - "time" -) - -type PinnedRoot struct { - TenantID string - RootKeyID string - PublicKey ed25519.PublicKey -} - -// Store is a tenant-scoped local trust view. The pinned root is configured -// out-of-band; a signed online bundle cannot replace it. -type Store struct { - mu sync.RWMutex - roots map[string]PinnedRoot - bundles map[string]TrustBundle - history map[string][]TrustBundle - now func() time.Time -} - -func NewStore(roots []PinnedRoot, now func() time.Time) (*Store, error) { - store := &Store{roots: make(map[string]PinnedRoot), bundles: make(map[string]TrustBundle), history: make(map[string][]TrustBundle), now: now} - if store.now == nil { - store.now = time.Now - } - for _, root := range roots { - if err := validateIdentifier("tenant_id", root.TenantID); err != nil { - return nil, err - } - if err := validateIdentifier("root_key_id", root.RootKeyID); err != nil { - return nil, err - } - if len(root.PublicKey) != ed25519.PublicKeySize { - return nil, fmt.Errorf("authority: tenant %q has invalid root public key", root.TenantID) - } - if _, exists := store.roots[root.TenantID]; exists { - return nil, fmt.Errorf("authority: duplicate pinned tenant %q", root.TenantID) - } - root.PublicKey = append(ed25519.PublicKey(nil), root.PublicKey...) - store.roots[root.TenantID] = root - } - return store, nil -} - -func (store *Store) Install(bundle TrustBundle) error { - return store.install(bundle, true, nil) -} - -func (store *Store) InstallWithCommit(bundle TrustBundle, commit func() error) error { - return store.install(bundle, true, commit) -} - -// InstallHistorical retains a root-signed bundle for verification of evidence -// created while its keys were valid. Expired state is never made active, and -// monotonic revision/revocation history is still enforced. -func (store *Store) InstallHistorical(bundle TrustBundle) error { - return store.install(bundle, false, nil) -} - -// InstallHistoricalWithCommit verifies and durably records a historical -// bundle before exposing it to historical key resolution. -func (store *Store) InstallHistoricalWithCommit(bundle TrustBundle, commit func() error) error { - return store.install(bundle, false, commit) -} - -func (store *Store) install(bundle TrustBundle, requireFresh bool, commit func() error) error { - if store == nil { - return fmt.Errorf("authority: trust store is not initialized") - } - store.mu.Lock() - defer store.mu.Unlock() - root, exists := store.roots[bundle.TenantID] - if !exists { - return fmt.Errorf("authority: tenant %q has no pinned root", bundle.TenantID) - } - if bundle.RootKeyID != root.RootKeyID { - return fmt.Errorf("authority: trust bundle root key is not pinned") - } - if requireFresh { - if err := bundle.Verify(root.PublicKey, store.now()); err != nil { - return err - } - } else { - if err := bundle.VerifySignature(root.PublicKey); err != nil { - return err - } - if bundle.IssuedAt > store.now().Unix()+int64(MaxBundleClockSkew/time.Second) { - return fmt.Errorf("authority: historical trust bundle is from the future") - } - } - history := store.history[bundle.TenantID] - if len(history) > 0 { - current := history[len(history)-1] - currentHash, _ := current.Hash() - candidateHash, _ := bundle.Hash() - if currentHash == candidateHash && current.Signature == bundle.Signature { - if commit != nil { - if err := commit(); err != nil { - return fmt.Errorf("authority: durable trust commit failed: %w", err) - } - } - return nil - } - if bundle.Revision <= current.Revision { - return fmt.Errorf("authority: trust bundle revision rollback") - } - if bundle.IssuedAt < current.IssuedAt { - return fmt.Errorf("authority: trust bundle issuance time rollback") - } - if bundle.PolicyRevision < current.PolicyRevision || bundle.RevocationEpoch < current.RevocationEpoch { - return fmt.Errorf("authority: trust bundle state rollback") - } - if narrowsAuthority(current, bundle) && bundle.RevocationEpoch == current.RevocationEpoch { - return fmt.Errorf("authority: key removal or narrowing requires a higher revocation epoch") - } - } - if commit != nil { - if err := commit(); err != nil { - return fmt.Errorf("authority: durable trust commit failed: %w", err) - } - } - store.history[bundle.TenantID] = append(store.history[bundle.TenantID], cloneBundle(bundle)) - now := store.now().Unix() - if bundle.IssuedAt <= now+int64(MaxBundleClockSkew/time.Second) && bundle.ExpiresAt >= now { - store.bundles[bundle.TenantID] = cloneBundle(bundle) - } else { - // A newer signed revision supersedes every older revision. If it is - // not usable now, fail closed instead of falling back to older state. - delete(store.bundles, bundle.TenantID) - } - return nil -} - -func (store *Store) IntentKey(_ context.Context, tenantID, agentID, keyID string) (ed25519.PublicKey, error) { - return store.resolve(tenantID, agentID, keyID, UsageIntent) -} - -func (store *Store) DecisionKey(_ context.Context, tenantID, keyID string) (ed25519.PublicKey, error) { - return store.resolve(tenantID, "", keyID, UsageDecision) -} - -func (store *Store) PolicyKey(tenantID, keyID string) (ed25519.PublicKey, error) { - return store.resolve(tenantID, "", keyID, UsagePolicy) -} - -// MandateKey resolves a tenant-wide delegated issuer key for signed mandates. -// Mandates are never signed by a workload-scoped key. -func (store *Store) MandateKey(_ context.Context, tenantID, keyID string) (ed25519.PublicKey, error) { - return store.resolve(tenantID, "", keyID, UsageMandate) -} - -func (store *Store) ApprovalKey(tenantID, keyID string) (ed25519.PublicKey, error) { - return store.resolve(tenantID, "", keyID, UsageApproval) -} - -func (store *Store) PolicyKeyAt(tenantID, keyID string, at time.Time) (ed25519.PublicKey, error) { - return store.resolveAt(tenantID, "", keyID, UsagePolicy, at) -} - -func (store *Store) IntentKeyAt(tenantID, agentID, keyID string, at time.Time) (ed25519.PublicKey, error) { - return store.resolveAt(tenantID, agentID, keyID, UsageIntent, at) -} - -func (store *Store) ApprovalKeyAt(tenantID, keyID string, at time.Time) (ed25519.PublicKey, error) { - return store.resolveAt(tenantID, "", keyID, UsageApproval, at) -} - -func (store *Store) DecisionKeyAt(tenantID, keyID string, at time.Time) (ed25519.PublicKey, error) { - return store.resolveAt(tenantID, "", keyID, UsageDecision, at) -} - -func (store *Store) ReceiptKeyAt(tenantID, agentID, keyID string, at time.Time) (ed25519.PublicKey, error) { - return store.resolveAt(tenantID, agentID, keyID, UsageReceipt, at) -} - -func (store *Store) History(tenantID string) []TrustBundle { - store.mu.RLock() - defer store.mu.RUnlock() - history := store.history[tenantID] - result := make([]TrustBundle, len(history)) - for index, bundle := range history { - result[index] = cloneBundle(bundle) - } - return result -} - -func (store *Store) Latest(tenantID string) (TrustBundle, bool) { - store.mu.RLock() - defer store.mu.RUnlock() - history := store.history[tenantID] - if len(history) == 0 { - return TrustBundle{}, false - } - return cloneBundle(history[len(history)-1]), true -} - -// Current returns the currently active, root-verified trust bundle. Unlike -// Latest, it never returns an expired or future historical revision. -func (store *Store) Current(_ context.Context, tenantID string) (TrustBundle, error) { - if store == nil { - return TrustBundle{}, fmt.Errorf("authority: trust store is not initialized") - } - store.mu.RLock() - defer store.mu.RUnlock() - bundle, err := store.activeBundleLocked(tenantID) - if err != nil { - return TrustBundle{}, err - } - return cloneBundle(bundle), nil -} - -func (store *Store) ManagesTenant(tenantID string) bool { - store.mu.RLock() - defer store.mu.RUnlock() - _, ok := store.roots[tenantID] - return ok -} - -func (store *Store) ManagedTenants() []string { - store.mu.RLock() - defer store.mu.RUnlock() - tenants := make([]string, 0, len(store.roots)) - for tenantID := range store.roots { - tenants = append(tenants, tenantID) - } - sort.Strings(tenants) - return tenants -} - -func (store *Store) ReceiptKey(tenantID, agentID, keyID string) (ed25519.PublicKey, error) { - return store.resolve(tenantID, agentID, keyID, UsageReceipt) -} - -func (store *Store) MinimumState(_ context.Context, tenantID string) (uint64, uint64, error) { - store.mu.RLock() - defer store.mu.RUnlock() - bundle, err := store.activeBundleLocked(tenantID) - if err != nil { - return 0, 0, err - } - return bundle.PolicyRevision, bundle.RevocationEpoch, nil -} - -func (store *Store) resolve(tenantID, agentID, keyID string, usage KeyUsage) (ed25519.PublicKey, error) { - store.mu.RLock() - defer store.mu.RUnlock() - bundle, err := store.activeBundleLocked(tenantID) - if err != nil { - return nil, err - } - now := store.now().Unix() - for _, key := range bundle.Keys { - if key.KeyID != keyID { - continue - } - if !key.permits(usage) || key.AgentID != agentID { - return nil, fmt.Errorf("authority: key %q is outside requested scope", keyID) - } - if key.NotBefore > now || key.ExpiresAt < now { - return nil, fmt.Errorf("authority: key %q is not currently valid", keyID) - } - return key.publicKey(), nil - } - return nil, fmt.Errorf("authority: key %q is not active", keyID) -} - -func (store *Store) resolveAt(tenantID, agentID, keyID string, usage KeyUsage, at time.Time) (ed25519.PublicKey, error) { - store.mu.RLock() - defer store.mu.RUnlock() - history := store.history[tenantID] - atUnix := at.Unix() - for bundleIndex := len(history) - 1; bundleIndex >= 0; bundleIndex-- { - bundle := history[bundleIndex] - if bundle.IssuedAt > atUnix { - continue - } - if bundle.ExpiresAt < atUnix { - return nil, fmt.Errorf("authority: trust bundle is not valid at requested time") - } - for _, key := range bundle.Keys { - if key.KeyID != keyID { - continue - } - if key.AgentID != agentID || !key.permits(usage) || key.NotBefore > atUnix || key.ExpiresAt < atUnix { - return nil, fmt.Errorf("authority: historical key %q is outside requested scope or validity", keyID) - } - return key.publicKey(), nil - } - return nil, fmt.Errorf("authority: historical key %q is not active at requested time", keyID) - } - return nil, fmt.Errorf("authority: historical key %q is not valid at requested time", keyID) -} - -func (store *Store) activeBundleLocked(tenantID string) (TrustBundle, error) { - bundle, exists := store.bundles[tenantID] - if !exists { - return TrustBundle{}, fmt.Errorf("authority: tenant %q has no active trust bundle", tenantID) - } - if bundle.ExpiresAt < store.now().Unix() { - return TrustBundle{}, fmt.Errorf("authority: tenant %q trust bundle is expired", tenantID) - } - return bundle, nil -} - -func narrowsAuthority(current, candidate TrustBundle) bool { - next := make(map[string]AuthorityKey, len(candidate.Keys)) - for _, key := range candidate.Keys { - next[key.KeyID] = key - } - for _, existing := range current.Keys { - replacement, exists := next[existing.KeyID] - if !exists || replacement.PublicKey != existing.PublicKey || replacement.AgentID != existing.AgentID || - replacement.NotBefore > existing.NotBefore || replacement.ExpiresAt < existing.ExpiresAt { - return true - } - for _, usage := range existing.Usages { - if !replacement.permits(usage) { - return true - } - } - } - return false -} - -func cloneBundle(bundle TrustBundle) TrustBundle { - clone := bundle - clone.Keys = make([]AuthorityKey, len(bundle.Keys)) - for index, key := range bundle.Keys { - clone.Keys[index] = key - clone.Keys[index].Usages = append([]KeyUsage(nil), key.Usages...) - } - return clone -} diff --git a/internal/managedsdk/authorityhttp/agent_request_sign.go b/internal/managedsdk/authorityhttp/agent_request_sign.go deleted file mode 100644 index 1bef1296..00000000 --- a/internal/managedsdk/authorityhttp/agent_request_sign.go +++ /dev/null @@ -1,155 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package authorityhttp - -import ( - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - "time" -) - -const ( - agentRequestDomain = "pilot-agent-request-v1" - agentRequestAgentIDHeader = "X-Pilot-Agent-Id" - agentRequestKeyIDHeader = "X-Pilot-Agent-Key-Id" - agentRequestTimestampHeader = "X-Pilot-Agent-Timestamp" - agentRequestNonceHeader = "X-Pilot-Agent-Nonce" - agentRequestSignatureHeader = "X-Pilot-Agent-Signature" -) - -var emptyAgentRequestBodyHash = sha256.Sum256(nil) - -// AgentRequestSigner proves which enrolled node is fetching account-specific -// policy or control-plane state. It is intentionally limited to bodyless GETs: -// node reports already carry their own signed, validated artifacts. -type AgentRequestSigner struct { - agentID string - keyID string - privateKey ed25519.PrivateKey - now func() time.Time - random io.Reader -} - -// NewAgentRequestSigner binds one active agent intent key to node-plane reads. -func NewAgentRequestSigner(agentID, keyID string, privateKey ed25519.PrivateKey) (*AgentRequestSigner, error) { - if !clientIdentifier(agentID) || !clientIdentifier(keyID) || len(privateKey) != ed25519.PrivateKeySize { - return nil, fmt.Errorf("authorityhttp: invalid agent request signing credentials") - } - return &AgentRequestSigner{ - agentID: agentID, keyID: keyID, - privateKey: append(ed25519.PrivateKey(nil), privateKey...), - now: time.Now, - random: rand.Reader, - }, nil -} - -// ConfigureAgentRequestSigning enables authenticated policy, mandate, and -// fleet reads. Configure the client before concurrent use. -func (client *Client) ConfigureAgentRequestSigning(agentID, keyID string, privateKey ed25519.PrivateKey) error { - if client == nil { - return fmt.Errorf("authorityhttp: client is not initialized") - } - signer, err := NewAgentRequestSigner(agentID, keyID, privateKey) - if err != nil { - return err - } - client.agentRequestSigner = signer - return nil -} - -// SignAgentRequest signs a bodyless GET for integrations that do not use -// Client. tenantID and agentID must exactly match the request query. -func SignAgentRequest(request *http.Request, tenantID, agentID, keyID string, privateKey ed25519.PrivateKey) error { - signer, err := NewAgentRequestSigner(agentID, keyID, privateKey) - if err != nil { - return err - } - return signer.Sign(request, tenantID, agentID) -} - -func (client *Client) signAgentRequest(request *http.Request, tenantID, agentID string) error { - // Optional signing preserves compatibility with older/self-hosted - // authorities. Hosted node-plane endpoints require the signature. - if client == nil || client.agentRequestSigner == nil { - return nil - } - return client.agentRequestSigner.Sign(request, tenantID, agentID) -} - -// Sign binds the method, normalized path and query, tenant, node identity, -// delegated key, short-lived timestamp, nonce, and empty-body digest. -func (signer *AgentRequestSigner) Sign(request *http.Request, tenantID, agentID string) error { - if signer == nil || request == nil || request.URL == nil || signer.now == nil || signer.random == nil || len(signer.privateKey) != ed25519.PrivateKeySize { - return fmt.Errorf("authorityhttp: agent request signer is not initialized") - } - if request.Method != http.MethodGet || (request.Body != nil && request.Body != http.NoBody) { - return fmt.Errorf("authorityhttp: agent request signing requires a bodyless GET") - } - if !clientIdentifier(tenantID) || agentID != signer.agentID || !clientIdentifier(agentID) { - return fmt.Errorf("authorityhttp: agent request identity mismatch") - } - query, err := strictAgentRequestQuery(request.URL, tenantID, agentID) - if err != nil { - return err - } - nonceBytes := make([]byte, 16) - if _, err := io.ReadFull(signer.random, nonceBytes); err != nil { - return fmt.Errorf("authorityhttp: generate agent request nonce: %w", err) - } - timestamp := strconv.FormatInt(signer.now().UTC().Unix(), 10) - nonce := hex.EncodeToString(nonceBytes) - canonical, err := canonicalAgentRequest(request.Method, request.URL, query, tenantID, agentID, signer.keyID, timestamp, nonce) - if err != nil { - return err - } - signature := ed25519.Sign(signer.privateKey, canonical) - request.Header.Set(agentRequestAgentIDHeader, agentID) - request.Header.Set(agentRequestKeyIDHeader, signer.keyID) - request.Header.Set(agentRequestTimestampHeader, timestamp) - request.Header.Set(agentRequestNonceHeader, nonce) - request.Header.Set(agentRequestSignatureHeader, base64.RawURLEncoding.EncodeToString(signature)) - return nil -} - -func strictAgentRequestQuery(target *url.URL, tenantID, agentID string) (string, error) { - if target == nil { - return "", fmt.Errorf("authorityhttp: agent request URL is required") - } - values, err := url.ParseQuery(target.RawQuery) - if err != nil || len(values["tenant_id"]) != 1 || len(values["agent_id"]) != 1 || values.Get("tenant_id") != tenantID || values.Get("agent_id") != agentID { - return "", fmt.Errorf("authorityhttp: agent request query identity mismatch") - } - return values.Encode(), nil -} - -func canonicalAgentRequest(method string, target *url.URL, query, tenantID, agentID, keyID, timestamp, nonce string) ([]byte, error) { - path := "" - if target != nil { - path = target.EscapedPath() - } - if method != http.MethodGet || path == "" || !strings.HasPrefix(path, "/") || !clientIdentifier(tenantID) || !clientIdentifier(agentID) || !clientIdentifier(keyID) || timestamp == "" || len(nonce) != 32 { - return nil, fmt.Errorf("authorityhttp: invalid canonical agent request") - } - fields := []string{ - agentRequestDomain, - method, - path, - query, - hex.EncodeToString(emptyAgentRequestBodyHash[:]), - tenantID, - agentID, - keyID, - timestamp, - nonce, - } - return []byte(strings.Join(fields, "\n")), nil -} diff --git a/internal/managedsdk/authorityhttp/agent_request_sign_test.go b/internal/managedsdk/authorityhttp/agent_request_sign_test.go deleted file mode 100644 index 0f35b9f8..00000000 --- a/internal/managedsdk/authorityhttp/agent_request_sign_test.go +++ /dev/null @@ -1,91 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package authorityhttp - -import ( - "crypto/ed25519" - "crypto/rand" - "encoding/base64" - "net/http" - "net/http/httptest" - "testing" -) - -func TestClientSignsEveryConfidentialAgentRead(t *testing.T) { - t.Parallel() - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - seen := make(map[string]int) - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - agentID := request.Header.Get(agentRequestAgentIDHeader) - keyID := request.Header.Get(agentRequestKeyIDHeader) - timestamp := request.Header.Get(agentRequestTimestampHeader) - nonce := request.Header.Get(agentRequestNonceHeader) - query, queryErr := strictAgentRequestQuery(request.URL, "tenant-a", "agent-a") - canonical, canonicalErr := canonicalAgentRequest(request.Method, request.URL, query, "tenant-a", agentID, keyID, timestamp, nonce) - signature, signatureErr := base64.RawURLEncoding.DecodeString(request.Header.Get(agentRequestSignatureHeader)) - if queryErr != nil || canonicalErr != nil || signatureErr != nil || agentID != "agent-a" || keyID != "agent-key-a" || !ed25519.Verify(publicKey, canonical, signature) { - t.Errorf("invalid authenticated request for %s", request.URL.String()) - writer.WriteHeader(http.StatusUnauthorized) - return - } - seen[request.URL.Path]++ - switch request.URL.Path { - case "/v1/fleet/commands": - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write([]byte(`{"commands":[]}`)) - case "/v1/fleet/state/mutations": - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write([]byte(`{"mutations":[]}`)) - case "/v1/trust-current": - writer.WriteHeader(http.StatusNotFound) - default: - writer.WriteHeader(http.StatusNoContent) - } - })) - defer server.Close() - - client, err := New(server.URL, server.Client()) - if err != nil { - t.Fatal(err) - } - if err := client.ConfigureAgentRequestSigning("agent-a", "agent-key-a", privateKey); err != nil { - t.Fatal(err) - } - ctx := t.Context() - if _, _, err := client.CurrentTrust(ctx, "tenant-a"); err != nil { - t.Fatal(err) - } - if _, _, err := client.Candidate(ctx, "tenant-a", "agent-a"); err != nil { - t.Fatal(err) - } - if _, _, err := client.CurrentPolicy(ctx, "tenant-a", "agent-a"); err != nil { - t.Fatal(err) - } - if _, _, err := client.CurrentMandateBundle(ctx, "tenant-a", "agent-a"); err != nil { - t.Fatal(err) - } - if _, err := client.FleetCommands(ctx, "tenant-a", "agent-a"); err != nil { - t.Fatal(err) - } - if _, _, err := client.FleetStateSnapshot(ctx, "tenant-a", "agent-a"); err != nil { - t.Fatal(err) - } - if _, err := client.FleetStateMutations(ctx, "tenant-a", "agent-a"); err != nil { - t.Fatal(err) - } - if _, _, err := client.FleetControl(ctx, "tenant-a", "agent-a"); err != nil { - t.Fatal(err) - } - - for _, path := range []string{ - "/v1/trust-current", "/v1/policy-candidate", "/v1/policy-current", "/v1/mandates-current", - "/v1/fleet/commands", "/v1/fleet/state/current", "/v1/fleet/state/mutations", "/v1/fleet/control", - } { - if seen[path] != 1 { - t.Errorf("%s signed requests = %d", path, seen[path]) - } - } -} diff --git a/internal/managedsdk/authorityhttp/client.go b/internal/managedsdk/authorityhttp/client.go deleted file mode 100644 index 9ef4f93a..00000000 --- a/internal/managedsdk/authorityhttp/client.go +++ /dev/null @@ -1,623 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package authorityhttp - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/pilot-protocol/common/decision" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/decisionhttp" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/decisionpolicy" -) - -type Client struct { - endpoint *url.URL - httpClient *http.Client - agentRequestSigner *AgentRequestSigner -} - -// TrustPublicationResult is the durable identity returned after the authority -// accepts a root-signed trust/revocation update through its management plane. -type TrustPublicationResult struct { - TenantID string `json:"tenant_id"` - Revision uint64 `json:"revision"` - BundleHash string `json:"bundle_hash"` -} - -func New(endpoint string, httpClient *http.Client) (*Client, error) { - parsed, err := url.Parse(endpoint) - if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { - return nil, fmt.Errorf("authorityhttp: invalid endpoint") - } - if parsed.Scheme != "https" && !(parsed.Scheme == "http" && authorityLoopback(parsed.Hostname())) { - return nil, fmt.Errorf("authorityhttp: endpoint must use HTTPS") - } - if parsed.Path == "" || parsed.Path == "/" { - parsed.Path = "/v1/policy-ack" - } - if httpClient == nil { - httpClient = &http.Client{Timeout: 10 * time.Second} - } - return &Client{endpoint: parsed, httpClient: httpClient}, nil -} - -func (client *Client) Acknowledge(ctx context.Context, ack authority.PolicyAcknowledgement) (authority.RolloutStatus, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: client is not initialized") - } - if err := ack.Validate(); err != nil { - return authority.RolloutStatus{}, err - } - body, err := json.Marshal(ack) - if err != nil { - return authority.RolloutStatus{}, err - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.endpoint.String(), bytes.NewReader(body)) - if err != nil { - return authority.RolloutStatus{}, err - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Accept", "application/json") - request.Header.Set("Idempotency-Key", ack.ID) - response, err := client.httpClient.Do(request) - if err != nil { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: acknowledge: %w", err) - } - defer response.Body.Close() - body, err = io.ReadAll(io.LimitReader(response.Body, (1<<20)+1)) - if err != nil { - return authority.RolloutStatus{}, err - } - if len(body) > 1<<20 { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: acknowledgement response is too large") - } - if response.StatusCode < 200 || response.StatusCode >= 300 { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: acknowledgement returned HTTP %d", response.StatusCode) - } - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.DisallowUnknownFields() - var status authority.RolloutStatus - if err := decoder.Decode(&status); err != nil { - return authority.RolloutStatus{}, err - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: trailing response data") - } - if status.Publication.ID != ack.PublicationID { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: acknowledgement status publication mismatch") - } - return status, nil -} - -// Candidate retrieves the newest unactivated signed policy publication that -// explicitly targets agentID. A false found result is normal: it means the -// authority has no staged change for that workload, not that the current -// policy permits an action. -func (client *Client) Candidate(ctx context.Context, tenantID, agentID string) (PublicationEnvelope, bool, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return PublicationEnvelope{}, false, fmt.Errorf("authorityhttp: client is not initialized") - } - if !clientIdentifier(tenantID) || !clientIdentifier(agentID) { - return PublicationEnvelope{}, false, fmt.Errorf("authorityhttp: tenant and agent identifiers are required") - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.route("/v1/policy-candidate", url.Values{"tenant_id": {tenantID}, "agent_id": {agentID}}), nil) - if err != nil { - return PublicationEnvelope{}, false, err - } - request.Header.Set("Accept", "application/json") - if err := client.signAgentRequest(request, tenantID, agentID); err != nil { - return PublicationEnvelope{}, false, err - } - response, err := client.httpClient.Do(request) - if err != nil { - return PublicationEnvelope{}, false, fmt.Errorf("authorityhttp: candidate: %w", err) - } - defer response.Body.Close() - if response.StatusCode == http.StatusNoContent { - return PublicationEnvelope{}, false, nil - } - if response.StatusCode != http.StatusOK { - return PublicationEnvelope{}, false, fmt.Errorf("authorityhttp: candidate returned HTTP %d", response.StatusCode) - } - var envelope PublicationEnvelope - if err := decodeResponse(response.Body, &envelope); err != nil { - return PublicationEnvelope{}, false, err - } - if err := envelope.Publication.Validate(); err != nil { - return PublicationEnvelope{}, false, fmt.Errorf("authorityhttp: invalid candidate publication: %w", err) - } - if err := envelope.Bundle.Validate(); err != nil { - return PublicationEnvelope{}, false, fmt.Errorf("authorityhttp: invalid candidate bundle: %w", err) - } - return envelope, true, nil -} - -// CurrentTrust retrieves the authority's current root-signed trust bundle for -// a tenant. The caller verifies it against an out-of-band pinned root before -// making it active. -func (client *Client) CurrentTrust(ctx context.Context, tenantID string) (authority.TrustBundle, bool, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return authority.TrustBundle{}, false, fmt.Errorf("authorityhttp: client is not initialized") - } - if !clientIdentifier(tenantID) { - return authority.TrustBundle{}, false, fmt.Errorf("authorityhttp: tenant identifier is required") - } - query := url.Values{"tenant_id": {tenantID}} - if client.agentRequestSigner != nil { - query.Set("agent_id", client.agentRequestSigner.agentID) - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.route("/v1/trust-current", query), nil) - if err != nil { - return authority.TrustBundle{}, false, err - } - request.Header.Set("Accept", "application/json") - if client.agentRequestSigner != nil { - if err := client.signAgentRequest(request, tenantID, client.agentRequestSigner.agentID); err != nil { - return authority.TrustBundle{}, false, err - } - } - response, err := client.httpClient.Do(request) - if err != nil { - return authority.TrustBundle{}, false, fmt.Errorf("authorityhttp: current trust: %w", err) - } - defer response.Body.Close() - if response.StatusCode == http.StatusNotFound { - return authority.TrustBundle{}, false, nil - } - if response.StatusCode != http.StatusOK { - return authority.TrustBundle{}, false, fmt.Errorf("authorityhttp: current trust returned HTTP %d", response.StatusCode) - } - var bundle authority.TrustBundle - if err := decodeResponse(response.Body, &bundle); err != nil { - return authority.TrustBundle{}, false, err - } - if err := bundle.Validate(); err != nil { - return authority.TrustBundle{}, false, fmt.Errorf("authorityhttp: invalid current trust: %w", err) - } - return bundle, true, nil -} - -// CurrentPolicy retrieves the authority's signed publication, bundle, and -// activation proof for one targeted agent. The caller must still verify all -// three objects against its locally pinned trust state. -func (client *Client) CurrentPolicy(ctx context.Context, tenantID, agentID string) (ActivePolicyEnvelope, bool, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return ActivePolicyEnvelope{}, false, fmt.Errorf("authorityhttp: client is not initialized") - } - if !clientIdentifier(tenantID) || !clientIdentifier(agentID) { - return ActivePolicyEnvelope{}, false, fmt.Errorf("authorityhttp: tenant and agent identifiers are required") - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.route("/v1/policy-current", url.Values{"tenant_id": {tenantID}, "agent_id": {agentID}}), nil) - if err != nil { - return ActivePolicyEnvelope{}, false, err - } - request.Header.Set("Accept", "application/json") - if err := client.signAgentRequest(request, tenantID, agentID); err != nil { - return ActivePolicyEnvelope{}, false, err - } - response, err := client.httpClient.Do(request) - if err != nil { - return ActivePolicyEnvelope{}, false, fmt.Errorf("authorityhttp: current policy: %w", err) - } - defer response.Body.Close() - if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotFound { - return ActivePolicyEnvelope{}, false, nil - } - if response.StatusCode != http.StatusOK { - return ActivePolicyEnvelope{}, false, fmt.Errorf("authorityhttp: current policy returned HTTP %d", response.StatusCode) - } - var envelope ActivePolicyEnvelope - if err := decodeResponse(response.Body, &envelope); err != nil { - return ActivePolicyEnvelope{}, false, err - } - if err := envelope.Publication.Validate(); err != nil { - return ActivePolicyEnvelope{}, false, fmt.Errorf("authorityhttp: invalid active policy publication: %w", err) - } - if err := envelope.Bundle.Validate(); err != nil { - return ActivePolicyEnvelope{}, false, fmt.Errorf("authorityhttp: invalid active policy bundle: %w", err) - } - if err := envelope.Activation.Validate(); err != nil { - return ActivePolicyEnvelope{}, false, fmt.Errorf("authorityhttp: invalid active policy activation: %w", err) - } - return envelope, true, nil -} - -// CurrentMandateBundle retrieves the complete, revisioned mandate snapshot -// for one workload. A false found result is a normal no-delegation state; it -// must never be treated as an authorization allow. -func (client *Client) CurrentMandateBundle(ctx context.Context, tenantID, agentID string) (decision.MandateBundle, bool, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return decision.MandateBundle{}, false, fmt.Errorf("authorityhttp: client is not initialized") - } - if !clientIdentifier(tenantID) || !clientIdentifier(agentID) { - return decision.MandateBundle{}, false, fmt.Errorf("authorityhttp: tenant and agent identifiers are required") - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.route("/v1/mandates-current", url.Values{"tenant_id": {tenantID}, "agent_id": {agentID}}), nil) - if err != nil { - return decision.MandateBundle{}, false, err - } - request.Header.Set("Accept", "application/json") - if err := client.signAgentRequest(request, tenantID, agentID); err != nil { - return decision.MandateBundle{}, false, err - } - response, err := client.httpClient.Do(request) - if err != nil { - return decision.MandateBundle{}, false, fmt.Errorf("authorityhttp: current mandates: %w", err) - } - defer response.Body.Close() - if response.StatusCode == http.StatusNoContent || response.StatusCode == http.StatusNotFound { - return decision.MandateBundle{}, false, nil - } - if response.StatusCode != http.StatusOK { - return decision.MandateBundle{}, false, fmt.Errorf("authorityhttp: current mandates returned HTTP %d", response.StatusCode) - } - var bundle decision.MandateBundle - if err := decodeResponse(response.Body, &bundle); err != nil { - return decision.MandateBundle{}, false, err - } - if err := bundle.Validate(); err != nil { - return decision.MandateBundle{}, false, fmt.Errorf("authorityhttp: invalid current mandate bundle: %w", err) - } - return bundle, true, nil -} - -// ManagementState retrieves the read-only control-plane snapshot used by an -// authenticated operator CLI or UI. The reference authority keeps this route -// loopback-only; callers using a remotely exposed management gateway supply -// its authenticated HTTP client. This helper validates structure only—local -// enforcement must still verify signed objects against its pinned root. -func (client *Client) ManagementState(ctx context.Context, tenantID string) (ManagementState, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return ManagementState{}, fmt.Errorf("authorityhttp: client is not initialized") - } - if !clientIdentifier(tenantID) { - return ManagementState{}, fmt.Errorf("authorityhttp: tenant identifier is required") - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.route("/v1/manage/control-state", url.Values{"tenant_id": {tenantID}}), nil) - if err != nil { - return ManagementState{}, err - } - request.Header.Set("Accept", "application/json") - response, err := client.httpClient.Do(request) - if err != nil { - return ManagementState{}, fmt.Errorf("authorityhttp: management state: %w", err) - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return ManagementState{}, fmt.Errorf("authorityhttp: management state returned HTTP %d", response.StatusCode) - } - var state ManagementState - if err := decodeResponse(response.Body, &state); err != nil { - return ManagementState{}, err - } - if err := validateManagementState(state, tenantID); err != nil { - return ManagementState{}, err - } - return state, nil -} - -// PublishTrust transports a pre-signed root trust bundle through the protected -// management boundary. It cannot create a root signature; the returned hash -// proves exactly which signed state the authority durably accepted. -func (client *Client) PublishTrust(ctx context.Context, bundle authority.TrustBundle) (TrustPublicationResult, error) { - if err := bundle.Validate(); err != nil || strings.TrimSpace(bundle.Signature) == "" { - return TrustPublicationResult{}, fmt.Errorf("authorityhttp: invalid signed trust bundle") - } - hash, err := bundle.Hash() - if err != nil { - return TrustPublicationResult{}, err - } - var result TrustPublicationResult - if err := client.managementPost(ctx, "/v1/manage/trust", bundle, hash, &result); err != nil { - return TrustPublicationResult{}, fmt.Errorf("authorityhttp: publish trust: %w", err) - } - if result.TenantID != bundle.TenantID || result.Revision != bundle.Revision || result.BundleHash != hash { - return TrustPublicationResult{}, fmt.Errorf("authorityhttp: published trust bundle binding mismatch") - } - return result, nil -} - -// Publish submits a pre-signed policy publication and its exact pre-signed -// bundle through the authenticated management boundary. This client never -// signs policy state; an operator CLI is only a transport for tenant-owned -// authority artifacts. -func (client *Client) Publish(ctx context.Context, publication authority.PolicyPublication, bundle authority.PolicyBundle) (authority.RolloutStatus, error) { - if err := publication.Validate(); err != nil || strings.TrimSpace(publication.Signature) == "" { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: invalid signed policy publication") - } - if err := bundle.Validate(); err != nil || strings.TrimSpace(bundle.Signature) == "" { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: invalid signed policy bundle") - } - idempotencyKey, err := publication.Hash() - if err != nil { - return authority.RolloutStatus{}, err - } - var status authority.RolloutStatus - if err := client.managementPost(ctx, "/v1/manage/policy", PublicationEnvelope{Publication: publication, Bundle: bundle}, idempotencyKey, &status); err != nil { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: publish policy: %w", err) - } - return status, nil -} - -// PublishMandateBundle submits a pre-signed full delegation snapshot. It is -// deliberately unable to create or modify tenant signatures. -func (client *Client) PublishMandateBundle(ctx context.Context, bundle decision.MandateBundle) (decision.MandateBundle, error) { - if err := bundle.Validate(); err != nil || strings.TrimSpace(bundle.Signature) == "" { - return decision.MandateBundle{}, fmt.Errorf("authorityhttp: invalid signed mandate bundle") - } - hash, err := bundle.Hash() - if err != nil { - return decision.MandateBundle{}, err - } - var published decision.MandateBundle - if err := client.managementPost(ctx, "/v1/manage/mandates", bundle, hash, &published); err != nil { - return decision.MandateBundle{}, fmt.Errorf("authorityhttp: publish mandate bundle: %w", err) - } - if published.TenantID != bundle.TenantID || published.SubjectAgentID != bundle.SubjectAgentID || published.Revision != bundle.Revision || published.Signature != bundle.Signature { - return decision.MandateBundle{}, fmt.Errorf("authorityhttp: published mandate bundle binding mismatch") - } - return published, nil -} - -// Receipts reads the bounded authenticated evidence view. Returned objects -// retain their original enforcement signatures for independent verification. -func (client *Client) Receipts(ctx context.Context, tenantID string, limit int) ([]decision.Receipt, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return nil, fmt.Errorf("authorityhttp: client is not initialized") - } - if !clientIdentifier(tenantID) || limit < 1 || limit > 1000 { - return nil, fmt.Errorf("authorityhttp: tenant identifier and receipt limit are required") - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.route("/v1/manage/receipts", url.Values{"tenant_id": {tenantID}, "limit": {strconv.Itoa(limit)}}), nil) - if err != nil { - return nil, err - } - request.Header.Set("Accept", "application/json") - response, err := client.httpClient.Do(request) - if err != nil { - return nil, fmt.Errorf("authorityhttp: receipts: %w", err) - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("authorityhttp: receipts returned HTTP %d", response.StatusCode) - } - var payload struct { - Receipts []decision.Receipt `json:"receipts"` - } - if err := decodeResponse(response.Body, &payload); err != nil { - return nil, err - } - for _, receipt := range payload.Receipts { - if receipt.TenantID != tenantID { - return nil, fmt.Errorf("authorityhttp: receipt tenant binding mismatch") - } - if err := receipt.Validate(); err != nil { - return nil, fmt.Errorf("authorityhttp: invalid receipt: %w", err) - } - } - return payload.Receipts, nil -} - -// Activate submits a pre-signed activation after the authority has verified -// the durable acknowledgement threshold. It cannot promote a bundle by time -// or client-side state alone. -func (client *Client) Activate(ctx context.Context, activation authority.PolicyActivation) (authority.RolloutStatus, error) { - if err := activation.Validate(); err != nil || strings.TrimSpace(activation.Signature) == "" { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: invalid signed policy activation") - } - var status authority.RolloutStatus - if err := client.managementPost(ctx, "/v1/manage/policy-activate", activation, activation.ID, &status); err != nil { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: activate policy: %w", err) - } - return status, nil -} - -// Simulate evaluates a signed candidate without publishing or activating it. -func (client *Client) Simulate(ctx context.Context, request SimulationRequest) (decisionpolicy.RolloutSimulation, error) { - if err := request.Publication.Validate(); err != nil || strings.TrimSpace(request.Publication.Signature) == "" { - return decisionpolicy.RolloutSimulation{}, fmt.Errorf("authorityhttp: invalid signed simulation publication") - } - if err := request.Bundle.Validate(); err != nil || strings.TrimSpace(request.Bundle.Signature) == "" { - return decisionpolicy.RolloutSimulation{}, fmt.Errorf("authorityhttp: invalid signed simulation bundle") - } - if len(request.Intents) != 0 && len(request.Inputs) != 0 { - return decisionpolicy.RolloutSimulation{}, fmt.Errorf("authorityhttp: simulation must use intents or inputs, not both") - } - for _, intent := range request.Intents { - if err := intent.Validate(); err != nil || strings.TrimSpace(intent.Signature) == "" { - return decisionpolicy.RolloutSimulation{}, fmt.Errorf("authorityhttp: invalid signed simulation intent") - } - } - for _, input := range request.Inputs { - if err := input.Validate(); err != nil || strings.TrimSpace(input.Intent.Signature) == "" { - return decisionpolicy.RolloutSimulation{}, fmt.Errorf("authorityhttp: invalid signed simulation input") - } - } - idempotencyKey, err := request.Publication.Hash() - if err != nil { - return decisionpolicy.RolloutSimulation{}, err - } - var simulation decisionpolicy.RolloutSimulation - if err := client.managementPost(ctx, "/v1/manage/policy-simulate", request, idempotencyKey, &simulation); err != nil { - return decisionpolicy.RolloutSimulation{}, fmt.Errorf("authorityhttp: simulate policy: %w", err) - } - return simulation, nil -} - -// Status returns the authority's durable rollout view for one publication. -func (client *Client) Status(ctx context.Context, publicationID string) (authority.RolloutStatus, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: client is not initialized") - } - if strings.TrimSpace(publicationID) == "" || len(publicationID) > 128 { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: publication identifier is required") - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.route("/v1/manage/policy-status", url.Values{"id": {publicationID}}), nil) - if err != nil { - return authority.RolloutStatus{}, err - } - request.Header.Set("Accept", "application/json") - response, err := client.httpClient.Do(request) - if err != nil { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: policy status: %w", err) - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return authority.RolloutStatus{}, fmt.Errorf("authorityhttp: policy status returned HTTP %d", response.StatusCode) - } - var status authority.RolloutStatus - if err := decodeResponse(response.Body, &status); err != nil { - return authority.RolloutStatus{}, err - } - return status, nil -} - -func (client *Client) managementPost(ctx context.Context, path string, payload any, idempotencyKey string, result any) error { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return fmt.Errorf("client is not initialized") - } - if strings.TrimSpace(idempotencyKey) == "" { - return fmt.Errorf("idempotency key is required") - } - body, err := json.Marshal(payload) - if err != nil { - return err - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.route(path, nil), bytes.NewReader(body)) - if err != nil { - return err - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Accept", "application/json") - request.Header.Set("Idempotency-Key", idempotencyKey) - response, err := client.httpClient.Do(request) - if err != nil { - return err - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return fmt.Errorf("management endpoint returned HTTP %d", response.StatusCode) - } - return decodeResponse(response.Body, result) -} - -func validateManagementState(state ManagementState, tenantID string) error { - if state.Trust.TenantID != tenantID || state.ActivePolicy.TenantID != tenantID { - return fmt.Errorf("authorityhttp: management state tenant binding mismatch") - } - if err := state.Trust.Validate(); err != nil { - return fmt.Errorf("authorityhttp: invalid management trust: %w", err) - } - if err := state.ActivePolicy.Validate(); err != nil { - return fmt.Errorf("authorityhttp: invalid management active policy: %w", err) - } - if state.ActivePolicy.Revision < state.Trust.PolicyRevision || state.ActivePolicy.RevocationEpoch < state.Trust.RevocationEpoch { - return fmt.Errorf("authorityhttp: management active policy is below trust state floor") - } - for _, status := range state.Rollouts { - if status.Publication.TenantID != tenantID { - return fmt.Errorf("authorityhttp: management rollout tenant binding mismatch") - } - if err := status.Publication.Validate(); err != nil { - return fmt.Errorf("authorityhttp: invalid management publication: %w", err) - } - if status.Activation != nil { - if status.Activation.TenantID != tenantID || status.Activation.PublicationID != status.Publication.ID { - return fmt.Errorf("authorityhttp: management activation binding mismatch") - } - if err := status.Activation.Validate(); err != nil { - return fmt.Errorf("authorityhttp: invalid management activation: %w", err) - } - } - } - for _, bundle := range state.MandateBundles { - if bundle.TenantID != tenantID { - return fmt.Errorf("authorityhttp: management mandate bundle tenant binding mismatch") - } - if err := bundle.Validate(); err != nil { - return fmt.Errorf("authorityhttp: invalid management mandate bundle: %w", err) - } - } - for _, receipt := range state.RecentReceipts { - if receipt.TenantID != tenantID { - return fmt.Errorf("authorityhttp: management receipt tenant binding mismatch") - } - if err := receipt.Validate(); err != nil { - return fmt.Errorf("authorityhttp: invalid management receipt: %w", err) - } - } - for _, workflow := range state.RecentWorkflows { - if workflow.Transaction.TenantID != tenantID { - return fmt.Errorf("authorityhttp: management workflow tenant binding mismatch") - } - if err := decisionhttp.ValidateWorkflowRecord(workflow); err != nil { - return fmt.Errorf("authorityhttp: invalid management workflow: %w", err) - } - } - if state.UsageExport != nil { - if err := state.UsageExport.Validate(); err != nil { - return fmt.Errorf("authorityhttp: invalid management usage export status: %w", err) - } - } - return nil -} - -func (client *Client) route(path string, query url.Values) string { - target := *client.endpoint - target.Path = path - target.RawQuery = query.Encode() - return target.String() -} - -func decodeResponse(body io.Reader, target any) error { - contents, err := io.ReadAll(io.LimitReader(body, (1<<20)+1)) - if err != nil { - return err - } - if len(contents) > 1<<20 { - return fmt.Errorf("authorityhttp: response is too large") - } - decoder := json.NewDecoder(bytes.NewReader(contents)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return err - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - return fmt.Errorf("authorityhttp: trailing response data") - } - return nil -} - -func clientIdentifier(value string) bool { - if len(value) == 0 || len(value) > 128 { - 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 authorityLoopback(host string) bool { - if strings.EqualFold(host, "localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} diff --git a/internal/managedsdk/authorityhttp/fleet_client.go b/internal/managedsdk/authorityhttp/fleet_client.go deleted file mode 100644 index 99780743..00000000 --- a/internal/managedsdk/authorityhttp/fleet_client.go +++ /dev/null @@ -1,232 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package authorityhttp - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" -) - -// FleetCommands retrieves the short-lived signed commands addressed to one -// agent. The caller must independently verify each command before execution. -func (client *Client) FleetCommands(ctx context.Context, tenantID, agentID string) ([]authority.FleetCommand, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return nil, fmt.Errorf("authorityhttp: client is not initialized") - } - if !clientIdentifier(tenantID) || !clientIdentifier(agentID) { - return nil, fmt.Errorf("authorityhttp: tenant and agent identifiers are required") - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.route("/v1/fleet/commands", url.Values{"tenant_id": {tenantID}, "agent_id": {agentID}}), nil) - if err != nil { - return nil, err - } - request.Header.Set("Accept", "application/json") - if err := client.signAgentRequest(request, tenantID, agentID); err != nil { - return nil, err - } - response, err := client.httpClient.Do(request) - if err != nil { - return nil, fmt.Errorf("authorityhttp: fleet commands: %w", err) - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("authorityhttp: fleet commands returned HTTP %d", response.StatusCode) - } - var envelope struct { - Commands []authority.FleetCommand `json:"commands"` - } - if err := decodeResponse(response.Body, &envelope); err != nil { - return nil, err - } - for _, command := range envelope.Commands { - if err := command.Validate(); err != nil || command.TenantID != tenantID || !command.TargetsAgent(agentID) { - return nil, fmt.Errorf("authorityhttp: invalid fleet command") - } - } - return envelope.Commands, nil -} - -func (client *Client) ReportFleetNode(ctx context.Context, report authority.FleetNodeReport) error { - if err := report.Validate(); err != nil { - return err - } - return client.postFleet(ctx, "/v1/fleet/report", report) -} - -func (client *Client) ReportFleetResult(ctx context.Context, result authority.FleetCommandResult) error { - if err := result.Validate(); err != nil { - return err - } - return client.postFleet(ctx, "/v1/fleet/result", result) -} - -func (client *Client) ReportFleetControlAcknowledgement(ctx context.Context, acknowledgement authority.FleetControlAcknowledgement) error { - if err := acknowledgement.Validate(); err != nil { - return err - } - return client.postFleet(ctx, "/v1/fleet/control-ack", acknowledgement) -} - -func (client *Client) ReportFleetActivity(ctx context.Context, activity authority.FleetActivity) error { - if err := activity.Validate(); err != nil { - return err - } - return client.postFleetIdempotent(ctx, "/v1/fleet/activity", activity, activity.ID) -} - -func (client *Client) ReportFleetStateSnapshot(ctx context.Context, snapshot authority.FleetStateSnapshot) error { - if err := snapshot.Validate(); err != nil { - return err - } - return client.postFleetIdempotent(ctx, "/v1/fleet/state/report", snapshot, snapshot.ID) -} - -func (client *Client) FleetStateSnapshot(ctx context.Context, tenantID, agentID string) (authority.FleetStateSnapshot, bool, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil || !clientIdentifier(tenantID) || !clientIdentifier(agentID) { - return authority.FleetStateSnapshot{}, false, fmt.Errorf("authorityhttp: tenant and agent identifiers are required") - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.route("/v1/fleet/state/current", url.Values{"tenant_id": {tenantID}, "agent_id": {agentID}}), nil) - if err != nil { - return authority.FleetStateSnapshot{}, false, err - } - request.Header.Set("Accept", "application/json") - if err := client.signAgentRequest(request, tenantID, agentID); err != nil { - return authority.FleetStateSnapshot{}, false, err - } - response, err := client.httpClient.Do(request) - if err != nil { - return authority.FleetStateSnapshot{}, false, fmt.Errorf("authorityhttp: fleet state snapshot: %w", err) - } - defer response.Body.Close() - if response.StatusCode == http.StatusNoContent { - return authority.FleetStateSnapshot{}, false, nil - } - if response.StatusCode != http.StatusOK { - return authority.FleetStateSnapshot{}, false, fmt.Errorf("authorityhttp: fleet state snapshot returned HTTP %d", response.StatusCode) - } - var snapshot authority.FleetStateSnapshot - if err := decodeResponse(response.Body, &snapshot); err != nil { - return authority.FleetStateSnapshot{}, false, err - } - if err := snapshot.Validate(); err != nil || snapshot.TenantID != tenantID || snapshot.AgentID != agentID { - return authority.FleetStateSnapshot{}, false, fmt.Errorf("authorityhttp: invalid fleet state snapshot") - } - return snapshot, true, nil -} - -func (client *Client) FleetStateMutations(ctx context.Context, tenantID, agentID string) ([]authority.FleetStateMutation, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil || !clientIdentifier(tenantID) || !clientIdentifier(agentID) { - return nil, fmt.Errorf("authorityhttp: tenant and agent identifiers are required") - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.route("/v1/fleet/state/mutations", url.Values{"tenant_id": {tenantID}, "agent_id": {agentID}}), nil) - if err != nil { - return nil, err - } - request.Header.Set("Accept", "application/json") - if err := client.signAgentRequest(request, tenantID, agentID); err != nil { - return nil, err - } - response, err := client.httpClient.Do(request) - if err != nil { - return nil, fmt.Errorf("authorityhttp: fleet state mutations: %w", err) - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("authorityhttp: fleet state mutations returned HTTP %d", response.StatusCode) - } - var envelope struct { - Mutations []authority.FleetStateMutation `json:"mutations"` - } - if err := decodeResponse(response.Body, &envelope); err != nil { - return nil, err - } - for _, mutation := range envelope.Mutations { - if err := mutation.Validate(); err != nil || mutation.TenantID != tenantID || mutation.AgentID != agentID { - return nil, fmt.Errorf("authorityhttp: invalid fleet state mutation") - } - } - return envelope.Mutations, nil -} - -func (client *Client) ReportFleetStateMutationResult(ctx context.Context, result authority.FleetStateMutationResult) error { - if err := result.Validate(); err != nil { - return err - } - return client.postFleet(ctx, "/v1/fleet/state/result", result) -} - -func (client *Client) FleetControl(ctx context.Context, tenantID, agentID string) (authority.FleetNodeControl, bool, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return authority.FleetNodeControl{}, false, fmt.Errorf("authorityhttp: client is not initialized") - } - if !clientIdentifier(tenantID) || !clientIdentifier(agentID) { - return authority.FleetNodeControl{}, false, fmt.Errorf("authorityhttp: tenant and agent identifiers are required") - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.route("/v1/fleet/control", url.Values{"tenant_id": {tenantID}, "agent_id": {agentID}}), nil) - if err != nil { - return authority.FleetNodeControl{}, false, err - } - request.Header.Set("Accept", "application/json") - if err := client.signAgentRequest(request, tenantID, agentID); err != nil { - return authority.FleetNodeControl{}, false, err - } - response, err := client.httpClient.Do(request) - if err != nil { - return authority.FleetNodeControl{}, false, fmt.Errorf("authorityhttp: fleet control: %w", err) - } - defer response.Body.Close() - if response.StatusCode == http.StatusNoContent { - return authority.FleetNodeControl{}, false, nil - } - if response.StatusCode != http.StatusOK { - return authority.FleetNodeControl{}, false, fmt.Errorf("authorityhttp: fleet control returned HTTP %d", response.StatusCode) - } - var control authority.FleetNodeControl - if err := decodeResponse(response.Body, &control); err != nil { - return authority.FleetNodeControl{}, false, err - } - if err := control.Validate(); err != nil || control.TenantID != tenantID || control.AgentID != agentID { - return authority.FleetNodeControl{}, false, fmt.Errorf("authorityhttp: invalid fleet control") - } - return control, true, nil -} - -func (client *Client) postFleet(ctx context.Context, path string, value any) error { - return client.postFleetIdempotent(ctx, path, value, "") -} - -func (client *Client) postFleetIdempotent(ctx context.Context, path string, value any, idempotencyKey string) error { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return fmt.Errorf("authorityhttp: client is not initialized") - } - body, err := json.Marshal(value) - if err != nil { - return err - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.route(path, nil), bytes.NewReader(body)) - if err != nil { - return err - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Accept", "application/json") - if idempotencyKey != "" { - request.Header.Set("Idempotency-Key", idempotencyKey) - } - response, err := client.httpClient.Do(request) - if err != nil { - return fmt.Errorf("authorityhttp: fleet post: %w", err) - } - defer response.Body.Close() - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20)) - if response.StatusCode != http.StatusOK { - return fmt.Errorf("authorityhttp: fleet post returned HTTP %d", response.StatusCode) - } - return nil -} diff --git a/internal/managedsdk/authorityhttp/types.go b/internal/managedsdk/authorityhttp/types.go deleted file mode 100644 index 1cb0e6aa..00000000 --- a/internal/managedsdk/authorityhttp/types.go +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Package authorityhttp contains only the managed node's public wire client. -// Hosted stores, account management, evaluators, and console handlers remain -// in the separately deployed private control plane. -package authorityhttp - -import ( - "encoding/json" - - "github.com/pilot-protocol/common/decision" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/decisionhttp" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/decisionpolicy" -) - -type PublicationEnvelope struct { - Publication authority.PolicyPublication `json:"publication"` - Bundle authority.PolicyBundle `json:"bundle"` -} - -type ActivePolicyEnvelope struct { - Publication authority.PolicyPublication `json:"publication"` - Bundle authority.PolicyBundle `json:"bundle"` - Activation authority.PolicyActivation `json:"activation"` -} - -type ManagementState struct { - Trust authority.TrustBundle `json:"trust"` - ActivePolicy authority.PolicyBundle `json:"active_policy"` - Rollouts []authority.RolloutStatus `json:"rollouts"` - MandateBundles []decision.MandateBundle `json:"mandate_bundles,omitempty"` - RecentReceipts []decision.Receipt `json:"recent_receipts,omitempty"` - RecentWorkflows []decisionhttp.WorkflowRecord `json:"recent_workflows,omitempty"` - UsageExport *decision.UsageExportStatus `json:"usage_export,omitempty"` -} - -type SimulationRequest struct { - Publication authority.PolicyPublication `json:"publication"` - Bundle authority.PolicyBundle `json:"bundle"` - Intents []decision.Intent `json:"intents"` - Inputs []decisionpolicy.SimulationInput `json:"inputs,omitempty"` -} - -const NodeEnrollmentVersion uint16 = 1 - -type NodeEnrollmentOptions struct { - ActionControl bool `json:"action_control"` - FleetControl bool `json:"fleet_control"` - StateSync bool `json:"state_sync"` - Apps bool `json:"apps"` -} - -// NodeEnrollmentClaimResponse is the atomic one-time response consumed by -// pilotctl. It intentionally carries no tenant root or platform credential. -type NodeEnrollmentClaimResponse struct { - Version uint16 `json:"version"` - EnrollmentID string `json:"enrollment_id"` - TenantID string `json:"tenant_id"` - AgentID string `json:"agent_id"` - HarnessID string `json:"harness_id"` - DisplayName string `json:"display_name"` - RunID string `json:"run_id"` - PublicOrigin string `json:"public_origin"` - FederationEndpoint string `json:"federation_endpoint"` - Options NodeEnrollmentOptions `json:"options"` - Credential json.RawMessage `json:"credential"` - Policy json.RawMessage `json:"policy"` - ClaimedAt int64 `json:"claimed_at"` -} diff --git a/internal/managedsdk/decisionhttp/client.go b/internal/managedsdk/decisionhttp/client.go deleted file mode 100644 index a3e91d35..00000000 --- a/internal/managedsdk/decisionhttp/client.go +++ /dev/null @@ -1,291 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Package decisionhttp implements the managed/self-hosted HTTP transport for -// decision.Authorizer. Signature and policy verification remain at the local -// enforcement point. -package decisionhttp - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/pilot-protocol/common/decision" -) - -const DefaultMaxResponseBytes int64 = 1 << 20 - -const ( - modelCallsHeader = "X-Pilot-Model-Calls" - modelInputTokensHeader = "X-Pilot-Model-Input-Tokens" // #nosec G101 -- HTTP usage-metering header name, not a credential. - modelOutputTokensHeader = "X-Pilot-Model-Output-Tokens" // #nosec G101 -- HTTP usage-metering header name, not a credential. -) - -type Client struct { - endpoint *url.URL - httpClient *http.Client - maxResponseBytes int64 - bearerToken string - workflowTenantID string - workflowAgentID string - workflowAgentRequestSigner func(*http.Request, string, string) error -} - -// AuthorizationRequest is the additive authorization wire envelope for a -// disclosure-bound V1 Intent. Plain V1 clients continue posting an Intent -// directly, preserving their signed bytes and endpoint compatibility. -type AuthorizationRequest struct { - Intent decision.Intent `json:"intent"` - Disclosure *decision.DisclosureBinding `json:"disclosure,omitempty"` -} - -// SemanticAuthorizationRequest is sent only to a configured semantic -// evaluator. The primary workload authorization handler deliberately rejects -// this envelope so a workload cannot choose or replace its active policy. -type SemanticAuthorizationRequest struct { - Intent decision.Intent `json:"intent"` - Disclosure *decision.DisclosureBinding `json:"disclosure,omitempty"` - Policy decision.SemanticPolicyContext `json:"semantic_policy"` -} - -// FederatedContentAuthorizationRequest is used only between Pilot's hosted -// authority and its hosted semantic runtime. It is never accepted by the -// workload /v1/authorize handler. -type FederatedContentAuthorizationRequest struct { - Intent decision.Intent `json:"intent"` - Content decision.FederatedContent `json:"federated_content"` -} - -// SemanticFederatedContentAuthorizationRequest additionally carries the -// reviewed policy clauses selected by the authority. A workload cannot choose -// or replace this context. -type SemanticFederatedContentAuthorizationRequest struct { - Intent decision.Intent `json:"intent"` - Content decision.FederatedContent `json:"federated_content"` - Policy decision.SemanticPolicyContext `json:"semantic_policy"` -} - -type Option func(*Client) - -func WithHTTPClient(httpClient *http.Client) Option { - return func(client *Client) { - if httpClient != nil { - client.httpClient = httpClient - } - } -} - -func WithMaxResponseBytes(limit int64) Option { - return func(client *Client) { - if limit > 0 { - client.maxResponseBytes = limit - } - } -} - -func WithBearerToken(token string) Option { - return func(client *Client) { - client.bearerToken = strings.TrimSpace(token) - } -} - -func New(endpoint string, options ...Option) (*Client, error) { - parsed, err := url.Parse(endpoint) - if err != nil { - return nil, fmt.Errorf("decisionhttp: parse endpoint: %w", err) - } - if parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" { - return nil, fmt.Errorf("decisionhttp: endpoint must not contain credentials, query, or fragment") - } - if parsed.Host == "" { - return nil, fmt.Errorf("decisionhttp: endpoint host is required") - } - if parsed.Scheme != "https" && !(parsed.Scheme == "http" && loopbackHost(parsed.Hostname())) { - return nil, fmt.Errorf("decisionhttp: endpoint must use HTTPS (loopback HTTP is allowed for tests)") - } - if parsed.Path == "" || parsed.Path == "/" { - parsed.Path = "/v1/authorize" - } - client := &Client{ - endpoint: parsed, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, - maxResponseBytes: DefaultMaxResponseBytes, - } - for _, option := range options { - option(client) - } - if len(client.bearerToken) > 16<<10 || strings.ContainsAny(client.bearerToken, "\r\n\x00") { - return nil, fmt.Errorf("decisionhttp: invalid bearer token") - } - return client, nil -} - -func (c *Client) Authorize(ctx context.Context, intent decision.Intent) (decision.Decision, error) { - return c.authorize(ctx, intent, intent) -} - -// AuthorizeDisclosure submits typed metadata alongside an Intent that already -// binds its canonical hash. Authorities whose evaluator cannot inspect -// disclosure metadata fail closed. -func (c *Client) AuthorizeDisclosure(ctx context.Context, intent decision.Intent, disclosure decision.DisclosureBinding) (decision.Decision, error) { - if err := disclosure.VerifyIntent(intent); err != nil { - return decision.Decision{}, err - } - return c.authorize(ctx, intent, AuthorizationRequest{Intent: intent, Disclosure: &disclosure}) -} - -func (c *Client) AuthorizeSemantic(ctx context.Context, intent decision.Intent, policy decision.SemanticPolicyContext) (decision.Decision, error) { - if err := policy.ValidateIntent(intent, intent.IssuedAt); err != nil { - return decision.Decision{}, err - } - return c.authorize(ctx, intent, SemanticAuthorizationRequest{Intent: intent, Policy: policy}) -} - -func (c *Client) AuthorizeSemanticDisclosure(ctx context.Context, intent decision.Intent, disclosure decision.DisclosureBinding, policy decision.SemanticPolicyContext) (decision.Decision, error) { - if err := disclosure.VerifyIntent(intent); err != nil { - return decision.Decision{}, err - } - if err := policy.ValidateIntent(intent, intent.IssuedAt); err != nil { - return decision.Decision{}, err - } - return c.authorize(ctx, intent, SemanticAuthorizationRequest{Intent: intent, Disclosure: &disclosure, Policy: policy}) -} - -func (c *Client) AuthorizeFederatedContent(ctx context.Context, intent decision.Intent, content decision.FederatedContent) (decision.Decision, error) { - if err := content.VerifyIntent(intent); err != nil { - return decision.Decision{}, err - } - return c.authorize(ctx, intent, FederatedContentAuthorizationRequest{Intent: intent, Content: content.Clone()}) -} - -func (c *Client) AuthorizeSemanticFederatedContent(ctx context.Context, intent decision.Intent, content decision.FederatedContent, policy decision.SemanticPolicyContext) (decision.Decision, error) { - if err := content.VerifyIntent(intent); err != nil { - return decision.Decision{}, err - } - if err := policy.ValidateIntent(intent, intent.IssuedAt); err != nil { - return decision.Decision{}, err - } - return c.authorize(ctx, intent, SemanticFederatedContentAuthorizationRequest{Intent: intent, Content: content.Clone(), Policy: policy}) -} - -func (c *Client) authorize(ctx context.Context, intent decision.Intent, payload any) (decision.Decision, error) { - if c == nil || c.endpoint == nil || c.httpClient == nil { - return decision.Decision{}, fmt.Errorf("decisionhttp: client is not initialized") - } - if err := intent.Validate(); err != nil { - return decision.Decision{}, err - } - if strings.TrimSpace(intent.Signature) == "" { - return decision.Decision{}, fmt.Errorf("decisionhttp: remote intents must be signed") - } - body, err := json.Marshal(payload) - if err != nil { - return decision.Decision{}, fmt.Errorf("decisionhttp: encode intent: %w", err) - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint.String(), bytes.NewReader(body)) - if err != nil { - return decision.Decision{}, fmt.Errorf("decisionhttp: build request: %w", err) - } - intentHash, err := intent.Hash() - if err != nil { - return decision.Decision{}, err - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Accept", "application/json") - request.Header.Set("Idempotency-Key", intentHash) - if c.bearerToken != "" { - request.Header.Set("Authorization", "Bearer "+c.bearerToken) - } - - response, err := c.httpClient.Do(request) - if err != nil { - return decision.Decision{}, fmt.Errorf("decisionhttp: authorize request: %w", err) - } - defer response.Body.Close() - if err := recordModelUsage(ctx, response.Header); err != nil { - return decision.Decision{}, err - } - limited := io.LimitReader(response.Body, c.maxResponseBytes+1) - responseBody, err := io.ReadAll(limited) - if err != nil { - return decision.Decision{}, fmt.Errorf("decisionhttp: read response: %w", err) - } - if int64(len(responseBody)) > c.maxResponseBytes { - return decision.Decision{}, fmt.Errorf("decisionhttp: response exceeds %d bytes", c.maxResponseBytes) - } - if response.StatusCode < 200 || response.StatusCode >= 300 { - return decision.Decision{}, fmt.Errorf("decisionhttp: authority returned HTTP %d", response.StatusCode) - } - decoder := json.NewDecoder(bytes.NewReader(responseBody)) - decoder.DisallowUnknownFields() - var result decision.Decision - if err := decoder.Decode(&result); err != nil { - return decision.Decision{}, fmt.Errorf("decisionhttp: decode decision: %w", err) - } - if err := ensureEOF(decoder); err != nil { - return decision.Decision{}, err - } - if err := result.Validate(); err != nil { - return decision.Decision{}, fmt.Errorf("decisionhttp: invalid decision: %w", err) - } - return result, nil -} - -func recordModelUsage(ctx context.Context, headers http.Header) error { - rawCalls, rawInput, rawOutput := headers.Get(modelCallsHeader), headers.Get(modelInputTokensHeader), headers.Get(modelOutputTokensHeader) - if rawCalls == "" && rawInput == "" && rawOutput == "" { - return nil - } - if rawCalls == "" || rawInput == "" || rawOutput == "" { - return fmt.Errorf("decisionhttp: incomplete model usage headers") - } - calls, err := strconv.ParseUint(rawCalls, 10, 64) - if err != nil { - return fmt.Errorf("decisionhttp: invalid model call usage") - } - input, err := strconv.ParseUint(rawInput, 10, 64) - if err != nil { - return fmt.Errorf("decisionhttp: invalid model input usage") - } - output, err := strconv.ParseUint(rawOutput, 10, 64) - if err != nil { - return fmt.Errorf("decisionhttp: invalid model output usage") - } - if err := decision.ReportModelUsage(ctx, decision.ModelUsage{ModelCalls: calls, InputTokens: input, OutputTokens: output}); err != nil { - return fmt.Errorf("decisionhttp: record model usage: %w", err) - } - return nil -} - -var _ decision.SemanticContextAuthorizer = (*Client)(nil) -var _ decision.SemanticContextDisclosureAuthorizer = (*Client)(nil) - -func ensureEOF(decoder *json.Decoder) error { - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - if err == nil { - return fmt.Errorf("decisionhttp: trailing JSON value") - } - return fmt.Errorf("decisionhttp: trailing response data: %w", err) - } - return nil -} - -func loopbackHost(host string) bool { - host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") - if host == "localhost" || strings.HasSuffix(host, ".localhost") { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} diff --git a/internal/managedsdk/decisionhttp/evaluator_client.go b/internal/managedsdk/decisionhttp/evaluator_client.go deleted file mode 100644 index 4c379cdc..00000000 --- a/internal/managedsdk/decisionhttp/evaluator_client.go +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decisionhttp - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - - "github.com/pilot-protocol/common/decision" -) - -const EvaluatorAttestationPath = "/v1/evaluator-attestation" - -func (client *Client) EvaluatorAttestation(ctx context.Context) (decision.EvaluatorAttestation, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return decision.EvaluatorAttestation{}, fmt.Errorf("decisionhttp: client is not initialized") - } - endpoint := *client.endpoint - endpoint.Path = EvaluatorAttestationPath - endpoint.RawPath = "" - request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) - if err != nil { - return decision.EvaluatorAttestation{}, fmt.Errorf("decisionhttp: build evaluator attestation request: %w", err) - } - request.Header.Set("Accept", "application/json") - response, err := client.httpClient.Do(request) - if err != nil { - return decision.EvaluatorAttestation{}, fmt.Errorf("decisionhttp: evaluator attestation request: %w", err) - } - defer response.Body.Close() - body, err := io.ReadAll(io.LimitReader(response.Body, client.maxResponseBytes+1)) - if err != nil { - return decision.EvaluatorAttestation{}, fmt.Errorf("decisionhttp: read evaluator attestation: %w", err) - } - if int64(len(body)) > client.maxResponseBytes { - return decision.EvaluatorAttestation{}, fmt.Errorf("decisionhttp: evaluator attestation exceeds %d bytes", client.maxResponseBytes) - } - if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - return decision.EvaluatorAttestation{}, fmt.Errorf("decisionhttp: evaluator attestation returned HTTP %d", response.StatusCode) - } - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.DisallowUnknownFields() - var attestation decision.EvaluatorAttestation - if err := decoder.Decode(&attestation); err != nil { - return decision.EvaluatorAttestation{}, fmt.Errorf("decisionhttp: decode evaluator attestation: %w", err) - } - if err := ensureEOF(decoder); err != nil { - return decision.EvaluatorAttestation{}, err - } - if err := attestation.Validate(); err != nil { - return decision.EvaluatorAttestation{}, fmt.Errorf("decisionhttp: invalid evaluator attestation: %w", err) - } - return attestation, nil -} - -func (client *Client) EvaluatorOrigin() (string, error) { - if client == nil || client.endpoint == nil { - return "", fmt.Errorf("decisionhttp: client is not initialized") - } - return canonicalClientOrigin(client.endpoint) -} - -func canonicalClientOrigin(endpoint *url.URL) (string, error) { - if endpoint == nil || endpoint.Scheme == "" || endpoint.Host == "" { - return "", fmt.Errorf("decisionhttp: evaluator endpoint is invalid") - } - return strings.ToLower(endpoint.Scheme) + "://" + strings.ToLower(endpoint.Host), nil -} diff --git a/internal/managedsdk/decisionhttp/federation_client.go b/internal/managedsdk/decisionhttp/federation_client.go deleted file mode 100644 index 4300d69e..00000000 --- a/internal/managedsdk/decisionhttp/federation_client.go +++ /dev/null @@ -1,176 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decisionhttp - -import ( - "context" - "fmt" - "net/http" - "strings" - - "github.com/pilot-protocol/common/decision" -) - -const FederationExchangeVersion uint16 = 1 - -type FederationExchangeState string - -const ( - FederationExchangeDenied FederationExchangeState = "denied" - FederationExchangeApprovalPending FederationExchangeState = "approval_pending" - FederationExchangeAuthorized FederationExchangeState = "authorized" - FederationExchangeCompleted FederationExchangeState = "completed" - FederationExchangeFailed FederationExchangeState = "failed" -) - -type FederationExchangeRequest struct { - Version uint16 `json:"version"` - Intent decision.Intent `json:"intent"` - Content decision.FederatedContent `json:"content"` -} - -type FederationExchangeResponse struct { - Version uint16 `json:"version"` - ExchangeID string `json:"exchange_id"` - AccountSlug string `json:"account_slug"` - State FederationExchangeState `json:"state"` - Decision decision.Decision `json:"decision"` - ApprovalTransactionID string `json:"approval_transaction_id,omitempty"` - ApprovalExpiresAt int64 `json:"approval_expires_at,omitempty"` - RetentionUntil int64 `json:"retention_until"` -} - -type FederationExchangeResultRequest struct { - Version uint16 `json:"version"` - ExecutionIntent decision.Intent `json:"execution_intent"` - Decision decision.Decision `json:"decision"` - Result decision.FederationResult `json:"result"` - Content *decision.FederatedContent `json:"content,omitempty"` -} - -type FederationExchangeResultResponse struct { - Version uint16 `json:"version"` - ExchangeID string `json:"exchange_id"` - State FederationExchangeState `json:"state"` - UpdatedAt int64 `json:"updated_at"` -} - -func (client *Client) SubmitFederationExchange(ctx context.Context, envelope FederationExchangeRequest) (FederationExchangeResponse, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return FederationExchangeResponse{}, fmt.Errorf("decisionhttp: client is not initialized") - } - if envelope.Version != FederationExchangeVersion { - return FederationExchangeResponse{}, fmt.Errorf("decisionhttp: invalid federation exchange version") - } - if err := envelope.Content.VerifyIntent(envelope.Intent); err != nil { - return FederationExchangeResponse{}, err - } - key, err := envelope.Intent.Hash() - if err != nil { - return FederationExchangeResponse{}, err - } - var response FederationExchangeResponse - if err := client.workflowJSON(ctx, http.MethodPost, "/v1/federation/exchanges", envelope, key, &response); err != nil { - return FederationExchangeResponse{}, err - } - if response.Version != FederationExchangeVersion || !validFederationIdentifier(response.ExchangeID) || !validAccountSlug(response.AccountSlug) { - return FederationExchangeResponse{}, fmt.Errorf("decisionhttp: invalid federation exchange response") - } - if err := response.Decision.Validate(); err != nil { - return FederationExchangeResponse{}, err - } - if response.Decision.IntentHash != key || response.Decision.TenantID != envelope.Intent.TenantID || response.Decision.AgentID != envelope.Intent.AgentID { - return FederationExchangeResponse{}, fmt.Errorf("decisionhttp: federation exchange response binding mismatch") - } - switch response.State { - case FederationExchangeDenied, FederationExchangeAuthorized: - case FederationExchangeApprovalPending: - if !validFederationIdentifier(response.ApprovalTransactionID) || response.ApprovalExpiresAt <= 0 { - return FederationExchangeResponse{}, fmt.Errorf("decisionhttp: invalid federation approval response") - } - default: - return FederationExchangeResponse{}, fmt.Errorf("decisionhttp: invalid federation exchange response state %q", response.State) - } - return response, nil -} - -func (client *Client) SubmitFederationExchangeResult(ctx context.Context, envelope FederationExchangeResultRequest) (FederationExchangeResultResponse, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return FederationExchangeResultResponse{}, fmt.Errorf("decisionhttp: client is not initialized") - } - if err := validateFederationResultEnvelope(envelope); err != nil { - return FederationExchangeResultResponse{}, err - } - var response FederationExchangeResultResponse - if err := client.workflowJSON(ctx, http.MethodPost, "/v1/federation/exchanges/result", envelope, envelope.Result.ID, &response); err != nil { - return FederationExchangeResultResponse{}, err - } - if response.Version != FederationExchangeVersion || response.ExchangeID != envelope.Result.ExchangeID || response.UpdatedAt <= 0 { - return FederationExchangeResultResponse{}, fmt.Errorf("decisionhttp: invalid federation result response") - } - switch response.State { - case FederationExchangeCompleted, FederationExchangeFailed, FederationExchangeDenied, FederationExchangeApprovalPending: - default: - return FederationExchangeResultResponse{}, fmt.Errorf("decisionhttp: invalid federation result state %q", response.State) - } - return response, nil -} - -func validateFederationResultEnvelope(envelope FederationExchangeResultRequest) error { - if envelope.Version != FederationExchangeVersion { - return fmt.Errorf("decisionhttp: invalid federation result version") - } - if err := envelope.ExecutionIntent.Validate(); err != nil { - return err - } - if err := envelope.Decision.Validate(); err != nil { - return err - } - if err := envelope.Result.Validate(); err != nil { - return err - } - intentHash, err := envelope.ExecutionIntent.Hash() - if err != nil || envelope.Decision.IntentHash != intentHash || envelope.Result.IntentHash != intentHash || envelope.Result.DecisionID != envelope.Decision.ID || envelope.Result.TenantID != envelope.ExecutionIntent.TenantID || envelope.Result.AgentID != envelope.ExecutionIntent.AgentID { - return fmt.Errorf("decisionhttp: federation result authorization mismatch") - } - if envelope.Content == nil { - if envelope.Result.ResponseDisclosureHash != "" { - return fmt.Errorf("decisionhttp: federation result response content is missing") - } - return nil - } - if err := envelope.Content.Validate(); err != nil { - return err - } - disclosureHash, err := envelope.Content.Disclosure.Hash() - if err != nil || disclosureHash != envelope.Result.ResponseDisclosureHash { - return fmt.Errorf("decisionhttp: federation result response binding mismatch") - } - return nil -} - -func validAccountSlug(value string) bool { - if len(value) < 1 || len(value) > 63 || 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 validFederationIdentifier(value string) bool { - if len(value) < 1 || len(value) > 128 { - return false - } - for index, character := range value { - if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || (index > 0 && strings.ContainsRune("._:-", character)) { - continue - } - return false - } - return true -} diff --git a/internal/managedsdk/decisionhttp/workflow_client.go b/internal/managedsdk/decisionhttp/workflow_client.go deleted file mode 100644 index 1865682d..00000000 --- a/internal/managedsdk/decisionhttp/workflow_client.go +++ /dev/null @@ -1,276 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decisionhttp - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - - "github.com/pilot-protocol/common/decision" -) - -// BeginWorkflow creates or retrieves the deterministic approval transaction -// bound to an already-issued approval_required decision. -func (client *Client) BeginWorkflow(ctx context.Context, envelope WorkflowBeginEnvelope) (WorkflowRecord, error) { - if err := envelope.Intent.Validate(); err != nil { - return WorkflowRecord{}, err - } - if err := envelope.Initial.Validate(); err != nil { - return WorkflowRecord{}, err - } - if envelope.Disclosure != nil { - if err := envelope.Disclosure.VerifyIntent(envelope.Intent); err != nil { - return WorkflowRecord{}, err - } - } - key, err := envelope.Initial.Hash() - if err != nil { - return WorkflowRecord{}, err - } - var record WorkflowRecord - if err := client.workflowJSON(ctx, http.MethodPost, "/v1/workflow/begin", envelope, key, &record); err != nil { - return WorkflowRecord{}, err - } - if err := validateWorkflowRecord(record); err != nil { - return WorkflowRecord{}, fmt.Errorf("decisionhttp: invalid workflow record: %w", err) - } - return record, nil -} - -// VoteWorkflow submits one signed vote. Replaying the same vote returns the -// durable record; a different vote from the same key is rejected by the -// authority. -func (client *Client) VoteWorkflow(ctx context.Context, transactionID string, vote decision.ApprovalVote) (WorkflowRecord, error) { - if err := vote.Validate(); err != nil { - return WorkflowRecord{}, err - } - key, err := vote.Hash() - if err != nil { - return WorkflowRecord{}, err - } - var record WorkflowRecord - if err := client.workflowJSON(ctx, http.MethodPost, "/v1/workflow/vote", WorkflowVoteEnvelope{TransactionID: transactionID, Vote: vote}, key, &record); err != nil { - return WorkflowRecord{}, err - } - if err := validateWorkflowRecord(record); err != nil { - return WorkflowRecord{}, fmt.Errorf("decisionhttp: invalid workflow record: %w", err) - } - return record, nil -} - -// ExecuteWorkflow submits a new workload-signed execution intent and returns -// the one canonical decision consumed for that approval transaction. Callers -// must still locally verify this signed decision before performing a side -// effect. -func (client *Client) ExecuteWorkflow(ctx context.Context, transactionID string, intent decision.Intent) (decision.Decision, error) { - if err := intent.Validate(); err != nil { - return decision.Decision{}, err - } - if intent.Signature == "" { - return decision.Decision{}, fmt.Errorf("decisionhttp: workflow execution intent must be signed") - } - key, err := intent.Hash() - if err != nil { - return decision.Decision{}, err - } - var result decision.Decision - if err := client.workflowJSON(ctx, http.MethodPost, "/v1/workflow/execute", WorkflowExecuteEnvelope{TransactionID: transactionID, Intent: intent}, key, &result); err != nil { - return decision.Decision{}, err - } - if err := result.Validate(); err != nil { - return decision.Decision{}, fmt.Errorf("decisionhttp: invalid workflow execution decision: %w", err) - } - return result, nil -} - -func (client *Client) WorkflowStatus(ctx context.Context, transactionID string) (WorkflowRecord, error) { - return client.workflowStatus(ctx, "/v1/workflow-status", transactionID, true) -} - -// ManagementWorkflowStatus returns a workflow record through the separately -// protected operator route. It is intended for a client configured with the -// authority's management mTLS identity; it does not grant an uncredentialed -// caller any management authority itself. -func (client *Client) ManagementWorkflowStatus(ctx context.Context, transactionID string) (WorkflowRecord, error) { - return client.workflowStatus(ctx, "/v1/manage/workflow-status", transactionID, false) -} - -// ConfigureWorkflowAgentRequestSigning authenticates participant status reads -// without changing the already signed workflow mutation envelopes. Configure -// the client before concurrent use. -func (client *Client) ConfigureWorkflowAgentRequestSigning(tenantID, agentID string, signer func(*http.Request, string, string) error) error { - if client == nil || !validWorkflowClientIdentifier(tenantID) || !validWorkflowClientIdentifier(agentID) || signer == nil { - return fmt.Errorf("decisionhttp: invalid workflow agent request signing configuration") - } - client.workflowTenantID = tenantID - client.workflowAgentID = agentID - client.workflowAgentRequestSigner = signer - return nil -} - -// ManagementWorkflows retrieves a bounded tenant-scoped list through the -// separately protected operator route. The caller supplies management mTLS; -// this method validates each returned workflow before exposing it to a UI or -// CLI. -func (client *Client) ManagementWorkflows(ctx context.Context, tenantID string, limit int) ([]WorkflowRecord, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return nil, fmt.Errorf("decisionhttp: client is not initialized") - } - if !validWorkflowTenant(tenantID) || limit < 1 || limit > 1000 { - return nil, fmt.Errorf("decisionhttp: workflow tenant and limit are required") - } - endpoint := *client.endpoint - endpoint.Path = "/v1/manage/workflows" - query := url.Values{} - query.Set("tenant_id", tenantID) - query.Set("limit", strconv.Itoa(limit)) - endpoint.RawQuery = query.Encode() - request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) - if err != nil { - return nil, err - } - request.Header.Set("Accept", "application/json") - var response WorkflowListResponse - if err := client.workflowResponse(request, &response); err != nil { - return nil, err - } - if len(response.Workflows) > limit { - return nil, fmt.Errorf("decisionhttp: workflow response exceeds requested limit") - } - for _, record := range response.Workflows { - if record.Transaction.TenantID != tenantID { - return nil, fmt.Errorf("decisionhttp: workflow tenant binding mismatch") - } - if err := validateWorkflowRecord(record); err != nil { - return nil, fmt.Errorf("decisionhttp: invalid workflow record: %w", err) - } - } - return append([]WorkflowRecord(nil), response.Workflows...), nil -} - -func (client *Client) workflowStatus(ctx context.Context, path, transactionID string, agentEndpoint bool) (WorkflowRecord, error) { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return WorkflowRecord{}, fmt.Errorf("decisionhttp: client is not initialized") - } - if strings.TrimSpace(transactionID) == "" { - return WorkflowRecord{}, fmt.Errorf("decisionhttp: workflow transaction identifier is required") - } - endpoint := *client.endpoint - endpoint.Path = path - query := url.Values{} - query.Set("transaction_id", transactionID) - if agentEndpoint && client.workflowAgentRequestSigner != nil { - query.Set("tenant_id", client.workflowTenantID) - query.Set("agent_id", client.workflowAgentID) - } - endpoint.RawQuery = query.Encode() - request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) - if err != nil { - return WorkflowRecord{}, err - } - request.Header.Set("Accept", "application/json") - if agentEndpoint && client.workflowAgentRequestSigner != nil { - if err := client.workflowAgentRequestSigner(request, client.workflowTenantID, client.workflowAgentID); err != nil { - return WorkflowRecord{}, err - } - } - var record WorkflowRecord - if err := client.workflowResponse(request, &record); err != nil { - return WorkflowRecord{}, err - } - if err := validateWorkflowRecord(record); err != nil { - return WorkflowRecord{}, fmt.Errorf("decisionhttp: invalid workflow record: %w", err) - } - return record, nil -} - -func validWorkflowClientIdentifier(value string) bool { - if len(value) == 0 || len(value) > 128 { - 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 -} - -// CancelWorkflow requests an authority-signed terminal cancellation through -// the separately protected management route. It binds retries to the exact -// transaction and reason, so a retry cannot accidentally mutate a different -// management action. -func (client *Client) CancelWorkflow(ctx context.Context, transactionID, reason string) (WorkflowRecord, error) { - transactionID = strings.TrimSpace(transactionID) - reason = strings.TrimSpace(reason) - if transactionID == "" || reason == "" { - return WorkflowRecord{}, fmt.Errorf("decisionhttp: workflow transaction identifier and cancellation reason are required") - } - sum := sha256.Sum256([]byte(transactionID + "\x00" + reason)) - var record WorkflowRecord - if err := client.workflowJSON(ctx, http.MethodPost, "/v1/manage/workflow-cancel", WorkflowCancelRequest{ - TransactionID: transactionID, - Reason: reason, - }, hex.EncodeToString(sum[:]), &record); err != nil { - return WorkflowRecord{}, err - } - if err := validateWorkflowRecord(record); err != nil { - return WorkflowRecord{}, fmt.Errorf("decisionhttp: invalid workflow record: %w", err) - } - return record, nil -} - -func (client *Client) workflowJSON(ctx context.Context, method, path string, value any, idempotencyKey string, target any) error { - if client == nil || client.endpoint == nil || client.httpClient == nil { - return fmt.Errorf("decisionhttp: client is not initialized") - } - body, err := json.Marshal(value) - if err != nil { - return fmt.Errorf("decisionhttp: encode workflow request: %w", err) - } - endpoint := *client.endpoint - endpoint.Path = path - endpoint.RawQuery = "" - request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("decisionhttp: build workflow request: %w", err) - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Accept", "application/json") - request.Header.Set("Idempotency-Key", idempotencyKey) - return client.workflowResponse(request, target) -} - -func (client *Client) workflowResponse(request *http.Request, target any) error { - response, err := client.httpClient.Do(request) - if err != nil { - return fmt.Errorf("decisionhttp: workflow request: %w", err) - } - defer response.Body.Close() - body, err := io.ReadAll(io.LimitReader(response.Body, client.maxResponseBytes+1)) - if err != nil { - return fmt.Errorf("decisionhttp: read workflow response: %w", err) - } - if int64(len(body)) > client.maxResponseBytes { - return fmt.Errorf("decisionhttp: workflow response exceeds %d bytes", client.maxResponseBytes) - } - if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - return fmt.Errorf("decisionhttp: workflow authority returned HTTP %d", response.StatusCode) - } - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return fmt.Errorf("decisionhttp: decode workflow response: %w", err) - } - return ensureEOF(decoder) -} diff --git a/internal/managedsdk/decisionhttp/workflow_client_test.go b/internal/managedsdk/decisionhttp/workflow_client_test.go deleted file mode 100644 index 5eab4b4a..00000000 --- a/internal/managedsdk/decisionhttp/workflow_client_test.go +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decisionhttp - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" -) - -func TestWorkflowStatusUsesConfiguredAgentRequestSigner(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.URL.Query().Get("tenant_id") != "tenant-a" || request.URL.Query().Get("agent_id") != "agent-a" || request.Header.Get("X-Test-Agent-Signature") != "present" { - t.Errorf("unbound status request: %s headers=%v", request.URL.String(), request.Header) - writer.WriteHeader(http.StatusUnauthorized) - return - } - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write([]byte(`{}`)) - })) - defer server.Close() - client, err := New(server.URL) - if err != nil { - t.Fatal(err) - } - called := false - if err := client.ConfigureWorkflowAgentRequestSigning("tenant-a", "agent-a", func(request *http.Request, tenantID, agentID string) error { - called = true - if tenantID != "tenant-a" || agentID != "agent-a" { - t.Fatalf("signer identity = %s/%s", tenantID, agentID) - } - request.Header.Set("X-Test-Agent-Signature", "present") - return nil - }); err != nil { - t.Fatal(err) - } - _, _ = client.WorkflowStatus(context.Background(), "transaction-a") - if !called { - t.Fatal("configured workflow status signer was not called") - } -} diff --git a/internal/managedsdk/decisionhttp/workflow_types.go b/internal/managedsdk/decisionhttp/workflow_types.go deleted file mode 100644 index 060a2a6e..00000000 --- a/internal/managedsdk/decisionhttp/workflow_types.go +++ /dev/null @@ -1,163 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decisionhttp - -import ( - "errors" - "sort" - - "github.com/pilot-protocol/common/decision" -) - -var ErrWorkflowConflict = errors.New("decisionhttp: approval workflow conflict") - -type WorkflowRecord struct { - Transaction decision.ApprovalTransaction `json:"transaction"` - Votes []decision.ApprovalVote `json:"votes,omitempty"` - Certificate *decision.ApprovalCertificate `json:"certificate,omitempty"` - Cancellation *decision.ApprovalCancellation `json:"cancellation,omitempty"` - ConsumedIntentID string `json:"consumed_intent_hash,omitempty"` - ConsumedDecision *decision.Decision `json:"consumed_decision,omitempty"` -} - -type WorkflowBeginEnvelope struct { - Intent decision.Intent `json:"intent"` - Initial decision.Decision `json:"initial_decision"` - Disclosure *decision.DisclosureBinding `json:"disclosure,omitempty"` -} - -type WorkflowVoteEnvelope struct { - TransactionID string `json:"transaction_id"` - Vote decision.ApprovalVote `json:"vote"` -} - -type WorkflowExecuteEnvelope struct { - TransactionID string `json:"transaction_id"` - Intent decision.Intent `json:"intent"` -} - -type WorkflowCancelRequest struct { - TransactionID string `json:"transaction_id"` - Reason string `json:"reason"` -} - -type WorkflowListResponse struct { - Workflows []WorkflowRecord `json:"workflows"` -} - -func ValidateWorkflowRecord(record WorkflowRecord) error { return validateWorkflowRecord(record) } - -func validateWorkflowRecord(record WorkflowRecord) error { - if err := record.Transaction.Validate(); err != nil { - return err - } - transactionHash, err := record.Transaction.Hash() - if err != nil { - return err - } - seenKeys := make(map[string]struct{}, len(record.Votes)) - for _, vote := range record.Votes { - if err := vote.Validate(); err != nil || vote.TransactionHash != transactionHash { - return ErrWorkflowConflict - } - if _, exists := seenKeys[vote.KeyID]; exists { - return ErrWorkflowConflict - } - seenKeys[vote.KeyID] = struct{}{} - } - if record.Certificate != nil { - if err := record.Certificate.Validate(); err != nil || record.Certificate.TransactionHash != transactionHash { - return ErrWorkflowConflict - } - if len(record.Votes) != len(record.Certificate.ApprovalVoteHashes) || len(record.Votes) < int(record.Transaction.RequiredApprovals) { - return ErrWorkflowConflict - } - certificateVotes := make(map[string]struct{}, len(record.Certificate.ApprovalVoteHashes)) - for _, voteHash := range record.Certificate.ApprovalVoteHashes { - certificateVotes[voteHash] = struct{}{} - } - for _, vote := range record.Votes { - if vote.Choice != decision.ApprovalVoteApprove { - return ErrWorkflowConflict - } - voteHash, err := vote.Hash() - if err != nil { - return ErrWorkflowConflict - } - if _, found := certificateVotes[voteHash]; !found { - return ErrWorkflowConflict - } - } - } - if record.Cancellation != nil { - if err := record.Cancellation.Validate(); err != nil || record.Cancellation.TransactionHash != transactionHash || record.Cancellation.TenantID != record.Transaction.TenantID { - return ErrWorkflowConflict - } - if record.Certificate != nil || record.ConsumedDecision != nil { - return ErrWorkflowConflict - } - } - if record.ConsumedDecision != nil { - if record.Certificate == nil || record.ConsumedIntentID == "" || record.ConsumedDecision.IntentHash != record.ConsumedIntentID { - return ErrWorkflowConflict - } - if err := record.ConsumedDecision.Validate(); err != nil { - return err - } - if record.ConsumedDecision.TenantID != record.Transaction.TenantID || - record.ConsumedDecision.AgentID != record.Transaction.AgentID || - record.ConsumedDecision.Outcome != record.Certificate.Outcome || - !equalDecisionConstraints(record.ConsumedDecision.Constraints, record.Certificate.Constraints) || - record.ConsumedDecision.PolicyRevision != record.Certificate.PolicyRevision || - record.ConsumedDecision.RevocationEpoch != record.Certificate.RevocationEpoch { - return ErrWorkflowConflict - } - } - return nil -} - -func equalDecisionConstraints(left, right []decision.Constraint) bool { - if len(left) != len(right) { - return false - } - left = append([]decision.Constraint(nil), left...) - right = append([]decision.Constraint(nil), right...) - sort.Slice(left, func(i, j int) bool { - if left[i].Key != left[j].Key { - return left[i].Key < left[j].Key - } - if left[i].Operator != left[j].Operator { - return left[i].Operator < left[j].Operator - } - return left[i].Value < left[j].Value - }) - sort.Slice(right, func(i, j int) bool { - if right[i].Key != right[j].Key { - return right[i].Key < right[j].Key - } - if right[i].Operator != right[j].Operator { - return right[i].Operator < right[j].Operator - } - return right[i].Value < right[j].Value - }) - for index := range left { - if left[index] != right[index] { - return false - } - } - return true -} - -func validWorkflowTenant(tenantID string) bool { - if len(tenantID) == 0 || len(tenantID) > 128 { - return false - } - for _, character := range tenantID { - if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || - (character >= '0' && character <= '9') || character == '-' || character == '_' || character == '.' { - continue - } - return false - } - return true -} diff --git a/internal/managedsdk/decisionpolicy/policy.go b/internal/managedsdk/decisionpolicy/policy.go deleted file mode 100644 index 11d83e22..00000000 --- a/internal/managedsdk/decisionpolicy/policy.go +++ /dev/null @@ -1,669 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Package decisionpolicy implements Pilot's small deterministic v1 action -// policy. It is both an authority.PolicyValidator and a decision.Authorizer / -// AuthorityCeiling, keeping managed answers below locally signed policy. -package decisionpolicy - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "mime" - "strings" - "sync" - "time" - "unicode/utf8" - - "github.com/pilot-protocol/common/decision" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" -) - -const ( - Engine = "pilot-action-policy" - EngineVersion = "1" - ContentType = "application/vnd.pilot.action-policy+json" - MaxRules = 256 -) - -type Document struct { - Version uint16 `json:"version"` - DefaultOutcome decision.Outcome `json:"default_outcome"` - Rules []Rule `json:"rules"` -} - -type Rule struct { - ID string `json:"id"` - Agents []string `json:"agents"` - Actions []string `json:"actions"` - ResourcePrefixes []string `json:"resource_prefixes"` - Risks []decision.RiskClass `json:"risks"` - Outcome decision.Outcome `json:"outcome"` - Reasons []string `json:"reasons,omitempty"` - Constraints []decision.Constraint `json:"constraints,omitempty"` - Approval *ApprovalPlan `json:"approval,omitempty"` - Disclosure *DisclosureRule `json:"disclosure,omitempty"` -} - -// DisclosureRule adds typed data-governance conditions to an ordinary action -// rule. All labels must be present; each non-empty list of content types, -// recipients, purposes, and residencies is an exact OR match. A rule with a -// disclosure clause never matches a bare V1 authorization request. -type DisclosureRule struct { - LabelsAll []string `json:"labels_all,omitempty"` - ContentTypes []string `json:"content_types,omitempty"` - Recipients []string `json:"recipients,omitempty"` - Purposes []string `json:"purposes,omitempty"` - Residencies []string `json:"residencies,omitempty"` - RetentionClasses []string `json:"retention_classes,omitempty"` -} - -// ApprovalPlan is a signed-policy declaration of the authority unlocked after -// a rule's approval_required outcome has satisfied its named-key threshold. -// It deliberately carries no approver display names or mutable UI state. -type ApprovalPlan struct { - ApproverKeyIDs []string `json:"approver_key_ids"` - RequiredApprovals uint16 `json:"required_approvals"` - ValiditySeconds int64 `json:"validity_seconds"` - Outcome decision.Outcome `json:"outcome"` - Constraints []decision.Constraint `json:"constraints,omitempty"` -} - -type compiled struct { - document Document -} - -func Compile(payload []byte) (*compiled, error) { - decoder := json.NewDecoder(bytes.NewReader(payload)) - decoder.DisallowUnknownFields() - var document Document - if err := decoder.Decode(&document); err != nil { - return nil, fmt.Errorf("decisionpolicy: decode: %w", err) - } - var trailing any - if err := decoder.Decode(&trailing); err != io.EOF { - if err == nil { - return nil, fmt.Errorf("decisionpolicy: trailing JSON value") - } - return nil, fmt.Errorf("decisionpolicy: trailing data: %w", err) - } - if document.Version != 1 { - return nil, fmt.Errorf("decisionpolicy: unsupported version %d", document.Version) - } - if document.DefaultOutcome != decision.Deny { - return nil, fmt.Errorf("decisionpolicy: v1 requires default_outcome %q", decision.Deny) - } - if len(document.Rules) > MaxRules { - return nil, fmt.Errorf("decisionpolicy: at most %d rules are allowed", MaxRules) - } - seen := make(map[string]struct{}, len(document.Rules)) - for index, rule := range document.Rules { - if err := validateRule(rule); err != nil { - return nil, fmt.Errorf("decisionpolicy: rule[%d]: %w", index, err) - } - if _, exists := seen[rule.ID]; exists { - return nil, fmt.Errorf("decisionpolicy: duplicate rule id %q", rule.ID) - } - seen[rule.ID] = struct{}{} - } - return &compiled{document: document}, nil -} - -func validateRule(rule Rule) error { - if !identifier(rule.ID) { - return fmt.Errorf("invalid id") - } - if err := validatePatterns("agents", rule.Agents, 128, func(pattern string) bool { - return pattern == "*" || identifier(pattern) - }); err != nil { - return err - } - if err := validatePatterns("actions", rule.Actions, 128, validActionPattern); err != nil { - return err - } - if err := validatePatterns("resource_prefixes", rule.ResourcePrefixes, 1024, validResourcePrefix); err != nil { - return err - } - if len(rule.Risks) == 0 || len(rule.Risks) > 4 { - return fmt.Errorf("risks must contain 1-4 values") - } - riskSeen := make(map[decision.RiskClass]struct{}, len(rule.Risks)) - for _, risk := range rule.Risks { - switch risk { - case decision.RiskLow, decision.RiskMedium, decision.RiskHigh, decision.RiskCritical: - default: - return fmt.Errorf("invalid risk %q", risk) - } - if _, exists := riskSeen[risk]; exists { - return fmt.Errorf("duplicate risk %q", risk) - } - riskSeen[risk] = struct{}{} - } - if err := validateOutcome(rule.Outcome, rule.Reasons, rule.Constraints); err != nil { - return err - } - if rule.Disclosure != nil { - if err := validateDisclosureRule(*rule.Disclosure); err != nil { - return fmt.Errorf("invalid disclosure rule: %w", err) - } - } - if rule.Outcome == decision.ApprovalRequired { - if rule.Approval != nil { - if err := validateApprovalPlan(*rule.Approval); err != nil { - return fmt.Errorf("invalid approval plan: %w", err) - } - } - } else if rule.Approval != nil { - return fmt.Errorf("approval plan requires approval_required outcome") - } - return nil -} - -func validateDisclosureRule(rule DisclosureRule) error { - if len(rule.LabelsAll)+len(rule.ContentTypes)+len(rule.Recipients)+len(rule.Purposes)+len(rule.Residencies)+len(rule.RetentionClasses) == 0 { - return fmt.Errorf("at least one disclosure condition is required") - } - if err := validateStringSet("labels_all", rule.LabelsAll, 16, validDisclosureLabel); err != nil { - return err - } - if err := validateStringSet("content_types", rule.ContentTypes, 16, validDisclosureContentType); err != nil { - return err - } - if err := validateStringSet("recipients", rule.Recipients, 16, validDisclosureText); err != nil { - return err - } - if err := validateStringSet("purposes", rule.Purposes, 16, validDisclosureText); err != nil { - return err - } - if err := validateStringSet("residencies", rule.Residencies, 16, validDisclosureResidency); err != nil { - return err - } - return validateStringSet("retention_classes", rule.RetentionClasses, 16, validDisclosureLabel) -} - -func validateStringSet(name string, values []string, max int, valid func(string) bool) error { - if len(values) == 0 { - return nil - } - if len(values) > max { - return fmt.Errorf("%s must contain at most %d values", name, max) - } - seen := make(map[string]struct{}, len(values)) - for _, value := range values { - if !valid(value) { - return fmt.Errorf("invalid %s value %q", name, value) - } - if _, exists := seen[value]; exists { - return fmt.Errorf("duplicate %s value %q", name, value) - } - seen[value] = struct{}{} - } - return nil -} - -func validDisclosureLabel(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 validDisclosureContentType(value string) bool { - if value == "" || len(value) > 128 || value != strings.ToLower(value) { - return false - } - mediaType, parameters, err := mime.ParseMediaType(value) - return err == nil && len(parameters) == 0 && mediaType == value && strings.Contains(mediaType, "/") -} - -func validDisclosureText(value string) bool { - if value == "" || len(value) > 256 || !utf8.ValidString(value) { - return false - } - for _, character := range value { - if character < 0x20 || character == 0x7f { - return false - } - } - return true -} - -func validDisclosureResidency(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 validateApprovalPlan(plan ApprovalPlan) error { - if len(plan.ApproverKeyIDs) == 0 || len(plan.ApproverKeyIDs) > decision.MaxApprovalKeys || plan.RequiredApprovals == 0 || int(plan.RequiredApprovals) > len(plan.ApproverKeyIDs) { - return fmt.Errorf("invalid approver threshold") - } - seen := make(map[string]struct{}, len(plan.ApproverKeyIDs)) - for _, keyID := range plan.ApproverKeyIDs { - if !identifier(keyID) { - return fmt.Errorf("invalid approver key ID %q", keyID) - } - if _, exists := seen[keyID]; exists { - return fmt.Errorf("duplicate approver key ID %q", keyID) - } - seen[keyID] = struct{}{} - } - if plan.ValiditySeconds <= 0 || plan.ValiditySeconds > int64(decision.MaxApprovalTransactionTTL/time.Second) { - return fmt.Errorf("validity_seconds must be in (0, %d]", int64(decision.MaxApprovalTransactionTTL/time.Second)) - } - if plan.Outcome != decision.Allow && plan.Outcome != decision.Constrain { - return fmt.Errorf("outcome must be allow or constrain") - } - if plan.Outcome == decision.Allow && len(plan.Constraints) != 0 { - return fmt.Errorf("allow outcome cannot carry constraints") - } - if plan.Outcome == decision.Constrain && len(plan.Constraints) == 0 { - return fmt.Errorf("constrain outcome requires constraints") - } - if err := validateOutcome(plan.Outcome, nil, plan.Constraints); err != nil { - return err - } - return nil -} - -func validateOutcome(outcome decision.Outcome, reasons []string, constraints []decision.Constraint) error { - probe := decision.Decision{ - Version: decision.SchemaVersion, ID: "policy-validation", IntentHash: strings.Repeat("0", 64), - TenantID: "policy-validation", AgentID: "policy-validation", Outcome: outcome, - Reasons: reasons, Constraints: constraints, ProviderID: "policy-validation", - IssuedAt: 1, ExpiresAt: 2, KeyID: "policy-validation", - } - if err := probe.Validate(); err != nil { - return fmt.Errorf("invalid outcome: %w", err) - } - return nil -} - -func validatePatterns(name string, patterns []string, maxLength int, valid func(string) bool) error { - if len(patterns) == 0 || len(patterns) > 64 { - return fmt.Errorf("%s must contain 1-64 values", name) - } - seen := make(map[string]struct{}, len(patterns)) - for _, pattern := range patterns { - if len(pattern) > maxLength || !valid(pattern) { - return fmt.Errorf("invalid %s pattern %q", name, pattern) - } - if _, exists := seen[pattern]; exists { - return fmt.Errorf("duplicate %s pattern %q", name, pattern) - } - seen[pattern] = struct{}{} - } - return nil -} - -func identifier(value string) bool { - if value == "" || len(value) > 128 { - return false - } - for _, character := range value { - if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || - (character >= '0' && character <= '9') || strings.ContainsRune("._:/@-", character) { - continue - } - return false - } - return true -} - -func validActionPattern(pattern string) bool { - if pattern == "*" { - return true - } - base := strings.TrimSuffix(pattern, "*") - if strings.Contains(base, "*") || base == "" || base[0] < 'a' || base[0] > 'z' { - return false - } - for _, character := range base[1:] { - if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || strings.ContainsRune("._-", character) { - continue - } - return false - } - return true -} - -func validResourcePrefix(prefix string) bool { - if prefix == "*" { - return true - } - if prefix == "" || strings.Contains(prefix, "*") { - return false - } - for _, character := range prefix { - if character < 0x20 || character == 0x7f { - return false - } - } - return true -} - -func (policy *compiled) evaluate(intent decision.Intent) decision.Decision { - for _, rule := range policy.document.Rules { - if matches(rule, intent, nil) { - return decision.Decision{ - Outcome: rule.Outcome, Reasons: append([]string(nil), rule.Reasons...), - Constraints: append([]decision.Constraint(nil), rule.Constraints...), - } - } - } - return decision.Decision{Outcome: policy.document.DefaultOutcome, Reasons: []string{"policy:default"}} -} - -func (policy *compiled) evaluateDisclosure(intent decision.Intent, disclosure decision.DisclosureBinding) decision.Decision { - for _, rule := range policy.document.Rules { - if matches(rule, intent, &disclosure) { - return decision.Decision{ - Outcome: rule.Outcome, Reasons: append([]string(nil), rule.Reasons...), - Constraints: append([]decision.Constraint(nil), rule.Constraints...), - } - } - } - return decision.Decision{Outcome: policy.document.DefaultOutcome, Reasons: []string{"policy:default"}} -} - -func (policy *compiled) approvalPlan(intent decision.Intent) *ApprovalPlan { - for _, rule := range policy.document.Rules { - if matches(rule, intent, nil) { - if rule.Outcome != decision.ApprovalRequired || rule.Approval == nil { - return nil - } - plan := cloneApprovalPlan(*rule.Approval) - return &plan - } - } - return nil -} - -func matches(rule Rule, intent decision.Intent, disclosure *decision.DisclosureBinding) bool { - return matchesExact(rule.Agents, intent.AgentID) && matchesPrefix(rule.Actions, intent.Action) && - matchesResourcePrefix(rule.ResourcePrefixes, intent.Resource) && matchesRisk(rule.Risks, intent.Risk) && - matchesDisclosure(rule.Disclosure, disclosure) -} - -func matchesDisclosure(rule *DisclosureRule, disclosure *decision.DisclosureBinding) bool { - if rule == nil { - return true - } - if disclosure == nil { - return false - } - return containsAll(disclosure.Labels, rule.LabelsAll) && matchesExactOrEmpty(rule.ContentTypes, disclosure.ContentType) && - matchesExactOrEmpty(rule.Recipients, disclosure.Recipient) && matchesExactOrEmpty(rule.Purposes, disclosure.Purpose) && - matchesExactOrEmpty(rule.Residencies, disclosure.Residency) && matchesExactOrEmpty(rule.RetentionClasses, disclosure.RetentionClass) -} - -func containsAll(values, expected []string) bool { - for _, wanted := range expected { - found := false - for _, value := range values { - if value == wanted { - found = true - break - } - } - if !found { - return false - } - } - return true -} - -func matchesExactOrEmpty(expected []string, value string) bool { - return len(expected) == 0 || matchesExact(expected, value) -} - -func matchesExact(patterns []string, value string) bool { - for _, pattern := range patterns { - if pattern == "*" || pattern == value { - return true - } - } - return false -} - -func matchesPrefix(patterns []string, value string) bool { - for _, pattern := range patterns { - if pattern == "*" || (strings.HasSuffix(pattern, "*") && strings.HasPrefix(value, strings.TrimSuffix(pattern, "*"))) || pattern == value { - return true - } - } - return false -} - -func matchesResourcePrefix(prefixes []string, value string) bool { - for _, prefix := range prefixes { - if prefix == "*" || strings.HasPrefix(value, prefix) { - return true - } - } - return false -} - -func matchesRisk(risks []decision.RiskClass, value decision.RiskClass) bool { - for _, risk := range risks { - if risk == value { - return true - } - } - return false -} - -type EngineInstance struct { - manager *authority.PolicyManager - mu sync.Mutex - cache map[string]cachedPolicy -} - -type cachedPolicy struct { - revision uint64 - policy *compiled -} - -func New(manager *authority.PolicyManager) (*EngineInstance, error) { - if manager == nil { - return nil, fmt.Errorf("decisionpolicy: policy manager is required") - } - return &EngineInstance{manager: manager, cache: make(map[string]cachedPolicy)}, nil -} - -func (engine *EngineInstance) ValidatePolicy(_ context.Context, name, version, contentType string, payload []byte) error { - if name != Engine || version != EngineVersion || contentType != ContentType { - return fmt.Errorf("decisionpolicy: unsupported engine tuple %q/%q/%q", name, version, contentType) - } - _, err := Compile(payload) - return err -} - -// Validator is usable while constructing the PolicyManager before an -// EngineInstance can reference that manager. -type Validator struct{} - -func (Validator) ValidatePolicy(_ context.Context, name, version, contentType string, payload []byte) error { - if name != Engine || version != EngineVersion || contentType != ContentType { - return fmt.Errorf("decisionpolicy: unsupported engine tuple %q/%q/%q", name, version, contentType) - } - _, err := Compile(payload) - return err -} - -func (engine *EngineInstance) Authorize(ctx context.Context, intent decision.Intent) (decision.Decision, error) { - bundle, policy, err := engine.active(ctx, intent.TenantID) - if err != nil { - return decision.Decision{}, err - } - result := policy.evaluate(intent) - result.PolicyRevision = bundle.Revision - result.RevocationEpoch = bundle.RevocationEpoch - return result, nil -} - -// AuthorizeDisclosure verifies the explicit metadata-to-Intent binding before -// evaluating the disclosure-aware deterministic policy. Rules with a -// disclosure clause never match an ordinary V1 request, so a tenant can make -// typed metadata a real authorization precondition rather than a UI hint. -func (engine *EngineInstance) AuthorizeDisclosure(ctx context.Context, intent decision.Intent, disclosure decision.DisclosureBinding) (decision.Decision, error) { - if err := disclosure.VerifyIntent(intent); err != nil { - return decision.Decision{}, err - } - bundle, policy, err := engine.active(ctx, intent.TenantID) - if err != nil { - return decision.Decision{}, err - } - result := policy.evaluateDisclosure(intent, disclosure) - result.PolicyRevision = bundle.Revision - result.RevocationEpoch = bundle.RevocationEpoch - return result, nil -} - -// ApprovalPlanFor returns the signed policy plan for an exact current -// approval_required result. It rejects stale state or a rule with no long-run -// plan, leaving the existing short-lived approval path available to policies -// that intentionally omit one. -func (engine *EngineInstance) ApprovalPlanFor(ctx context.Context, intent decision.Intent, initial decision.Decision) (ApprovalPlan, error) { - bundle, policy, err := engine.active(ctx, intent.TenantID) - if err != nil { - return ApprovalPlan{}, err - } - if initial.Outcome != decision.ApprovalRequired || initial.PolicyRevision != bundle.Revision || initial.RevocationEpoch != bundle.RevocationEpoch { - return ApprovalPlan{}, fmt.Errorf("decisionpolicy: initial approval decision is not current") - } - plan := policy.approvalPlan(intent) - if plan == nil { - return ApprovalPlan{}, fmt.Errorf("decisionpolicy: matching rule has no long-running approval plan") - } - return *plan, nil -} - -// ApprovalPlanForDisclosure resolves an approval plan using the same typed -// rule that issued a disclosure-bound approval_required Decision. Legacy -// ApprovalPlanFor deliberately cannot match those rules, preventing a caller -// from opening a long-running approval path after omitting data metadata. -func (engine *EngineInstance) ApprovalPlanForDisclosure(ctx context.Context, intent decision.Intent, initial decision.Decision, disclosure decision.DisclosureBinding) (ApprovalPlan, error) { - if err := disclosure.VerifyIntent(intent); err != nil { - return ApprovalPlan{}, err - } - bundle, policy, err := engine.active(ctx, intent.TenantID) - if err != nil { - return ApprovalPlan{}, err - } - if initial.Outcome != decision.ApprovalRequired || initial.PolicyRevision != bundle.Revision || initial.RevocationEpoch != bundle.RevocationEpoch { - return ApprovalPlan{}, fmt.Errorf("decisionpolicy: initial approval decision is not current") - } - plan := policy.approvalPlanDisclosure(intent, disclosure) - if plan == nil { - return ApprovalPlan{}, fmt.Errorf("decisionpolicy: matching disclosure rule has no long-running approval plan") - } - return *plan, nil -} - -func cloneApprovalPlan(plan ApprovalPlan) ApprovalPlan { - return ApprovalPlan{ - ApproverKeyIDs: append([]string(nil), plan.ApproverKeyIDs...), RequiredApprovals: plan.RequiredApprovals, - ValiditySeconds: plan.ValiditySeconds, Outcome: plan.Outcome, - Constraints: append([]decision.Constraint(nil), plan.Constraints...), - } -} - -func (engine *EngineInstance) Check(ctx context.Context, intent decision.Intent, remote decision.Decision) error { - _, policy, err := engine.active(ctx, intent.TenantID) - if err != nil { - return err - } - return checkLocalOutcome(policy.evaluate(intent), remote) -} - -// CheckDisclosure proves that a remote signed decision is no broader than the -// locally installed policy for the same typed disclosure. This is the method -// called by governed receivers and brokers before they release a message, -// file, stream, or event. -func (engine *EngineInstance) CheckDisclosure(ctx context.Context, intent decision.Intent, remote decision.Decision, disclosure decision.DisclosureBinding) error { - if err := disclosure.VerifyIntent(intent); err != nil { - return err - } - _, policy, err := engine.active(ctx, intent.TenantID) - if err != nil { - return err - } - return checkLocalOutcome(policy.evaluateDisclosure(intent, disclosure), remote) -} - -func checkLocalOutcome(local, remote decision.Decision) error { - if remote.Outcome == decision.Deny || remote.Outcome == decision.ApprovalRequired { - return nil - } - switch local.Outcome { - case decision.Deny, decision.ApprovalRequired: - return fmt.Errorf("decisionpolicy: remote outcome expands local %s", local.Outcome) - case decision.Constrain: - if remote.Outcome != decision.Constrain || !containsConstraints(remote.Constraints, local.Constraints) { - return fmt.Errorf("decisionpolicy: remote outcome drops local constraints") - } - case decision.Allow: - if remote.Outcome != decision.Allow && remote.Outcome != decision.Constrain { - return fmt.Errorf("decisionpolicy: unsupported remote outcome %s", remote.Outcome) - } - default: - return fmt.Errorf("decisionpolicy: invalid local outcome") - } - return nil -} - -func (engine *EngineInstance) active(ctx context.Context, tenantID string) (authority.PolicyBundle, *compiled, error) { - bundle, err := engine.manager.Active(ctx, tenantID) - if err != nil { - return authority.PolicyBundle{}, nil, err - } - engine.mu.Lock() - defer engine.mu.Unlock() - if cached, exists := engine.cache[tenantID]; exists && cached.revision == bundle.Revision { - return bundle, cached.policy, nil - } - policy, err := Compile(bundle.Payload) - if err != nil { - return authority.PolicyBundle{}, nil, fmt.Errorf("decisionpolicy: active policy no longer validates: %w", err) - } - engine.cache[tenantID] = cachedPolicy{revision: bundle.Revision, policy: policy} - return bundle, policy, nil -} - -func containsConstraints(actual, required []decision.Constraint) bool { - for _, want := range required { - found := false - for _, candidate := range actual { - if candidate == want { - found = true - break - } - } - if !found { - return false - } - } - return true -} - -var _ authority.PolicyValidator = Validator{} -var _ decision.Authorizer = (*EngineInstance)(nil) -var _ decision.DisclosureAuthorizer = (*EngineInstance)(nil) -var _ decision.AuthorityCeiling = (*EngineInstance)(nil) -var _ decision.DisclosureCeiling = (*EngineInstance)(nil) diff --git a/internal/managedsdk/decisionpolicy/policy_test.go b/internal/managedsdk/decisionpolicy/policy_test.go deleted file mode 100644 index 6d30a994..00000000 --- a/internal/managedsdk/decisionpolicy/policy_test.go +++ /dev/null @@ -1,271 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decisionpolicy - -import ( - "context" - "crypto/ed25519" - "crypto/rand" - "encoding/base64" - "encoding/json" - "strings" - "testing" - "time" - - "github.com/pilot-protocol/common/decision" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" -) - -func policyPayload(t *testing.T) []byte { - t.Helper() - document := Document{ - Version: 1, DefaultOutcome: decision.Deny, - Rules: []Rule{ - { - ID: "bounded-wallet", Agents: []string{"agent-1"}, Actions: []string{"wallet.*"}, - ResourcePrefixes: []string{"merchant:"}, Risks: []decision.RiskClass{decision.RiskHigh}, - Outcome: decision.Constrain, Reasons: []string{"within mandate"}, - Constraints: []decision.Constraint{{Key: "amount", Operator: "max", Value: "100"}}, - }, - { - ID: "messages", Agents: []string{"*"}, Actions: []string{"message.send"}, - ResourcePrefixes: []string{"*"}, Risks: []decision.RiskClass{decision.RiskLow}, - Outcome: decision.Allow, Reasons: []string{"low risk"}, - }, - }, - } - payload, err := json.Marshal(document) - if err != nil { - t.Fatal(err) - } - return payload -} - -func installedEngine(t *testing.T, now time.Time) (*EngineInstance, authority.PolicyBundle) { - return installedEngineWithPayload(t, now, policyPayload(t)) -} - -func installedEngineWithPayload(t *testing.T, now time.Time, payload []byte) (*EngineInstance, authority.PolicyBundle) { - t.Helper() - rootPublic, rootPrivate, _ := ed25519.GenerateKey(rand.Reader) - issuerPublic, issuerPrivate, _ := ed25519.GenerateKey(rand.Reader) - trustBundle := authority.TrustBundle{ - Version: authority.SchemaVersion, TenantID: "tenant-a", Revision: 1, - PolicyRevision: 7, RevocationEpoch: 3, IssuedAt: now.Unix(), - ExpiresAt: now.Add(time.Hour).Unix(), RootKeyID: "root-1", - Keys: []authority.AuthorityKey{{ - KeyID: "policy-key-1", PublicKey: base64.StdEncoding.EncodeToString(issuerPublic), - Usages: []authority.KeyUsage{authority.UsagePolicy}, NotBefore: now.Add(-time.Minute).Unix(), - ExpiresAt: now.Add(30 * time.Minute).Unix(), - }}, - } - if err := trustBundle.Sign(rootPrivate); err != nil { - t.Fatal(err) - } - trust, err := authority.NewStore([]authority.PinnedRoot{{TenantID: "tenant-a", RootKeyID: "root-1", PublicKey: rootPublic}}, func() time.Time { return now }) - if err != nil { - t.Fatal(err) - } - if err := trust.Install(trustBundle); err != nil { - t.Fatal(err) - } - manager, err := authority.NewPolicyManager(trust, Validator{}, func() time.Time { return now }) - if err != nil { - t.Fatal(err) - } - bundle := authority.NewPolicyBundle( - "tenant-a", 7, 3, now, now, now.Add(10*time.Minute), - Engine, EngineVersion, ContentType, "policy-key-1", payload, - ) - if err := bundle.Sign(issuerPrivate); err != nil { - t.Fatal(err) - } - if err := manager.Install(context.Background(), bundle); err != nil { - t.Fatal(err) - } - engine, err := New(manager) - if err != nil { - t.Fatal(err) - } - return engine, bundle -} - -func TestApprovalPlanIsSignedPolicyScopedAndCurrent(t *testing.T) { - t.Parallel() - now := time.Unix(1785500000, 0) - payload, err := json.Marshal(Document{ - Version: 1, DefaultOutcome: decision.Deny, - Rules: []Rule{{ - ID: "approved-payment", Agents: []string{"agent-1"}, Actions: []string{"wallet.pay"}, ResourcePrefixes: []string{"merchant:"}, - Risks: []decision.RiskClass{decision.RiskHigh}, Outcome: decision.ApprovalRequired, Reasons: []string{"dual control"}, - Approval: &ApprovalPlan{ApproverKeyIDs: []string{"approval-key-a", "approval-key-b"}, RequiredApprovals: 2, ValiditySeconds: 7200, - Outcome: decision.Constrain, Constraints: []decision.Constraint{{Key: "amount", Operator: "max", Value: "100"}}}, - }}, - }) - if err != nil { - t.Fatal(err) - } - engine, bundle := installedEngineWithPayload(t, now, payload) - intent := decision.Intent{Version: decision.SchemaVersion, ID: "plan-intent", TenantID: "tenant-a", AgentID: "agent-1", Action: "wallet.pay", Resource: "merchant:42", PayloadHash: decision.HashPayload([]byte("payment")), Risk: decision.RiskHigh, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), Nonce: strings.Repeat("1", 32), KeyID: "agent-key-1"} - initial, err := engine.Authorize(context.Background(), intent) - if err != nil || initial.Outcome != decision.ApprovalRequired { - t.Fatalf("initial=%+v err=%v", initial, err) - } - plan, err := engine.ApprovalPlanFor(context.Background(), intent, initial) - if err != nil || plan.RequiredApprovals != 2 || plan.Outcome != decision.Constrain || plan.ValiditySeconds != 7200 { - t.Fatalf("approval plan=%+v err=%v", plan, err) - } - stale := initial - stale.PolicyRevision = bundle.Revision - 1 - if _, err := engine.ApprovalPlanFor(context.Background(), intent, stale); err == nil { - t.Fatal("stale initial decision received an approval plan") - } -} - -func TestDeterministicEvaluatorAndLocalCeiling(t *testing.T) { - t.Parallel() - now := time.Unix(1785500000, 0) - engine, bundle := installedEngine(t, now) - intent := decision.Intent{ - Version: decision.SchemaVersion, ID: "intent-1", TenantID: "tenant-a", AgentID: "agent-1", - Action: "wallet.pay", Resource: "merchant:42", PayloadHash: decision.HashPayload([]byte("payment")), - Risk: decision.RiskHigh, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), - Nonce: strings.Repeat("0", 32), KeyID: "agent-key-1", - } - local, err := engine.Authorize(context.Background(), intent) - if err != nil { - t.Fatal(err) - } - if local.Outcome != decision.Constrain || local.PolicyRevision != bundle.Revision || len(local.Constraints) != 1 { - t.Fatalf("unexpected local result: %+v", local) - } - if err := engine.Check(context.Background(), intent, decision.Decision{Outcome: decision.Allow}); err == nil { - t.Fatal("remote allow expanded a local constraint") - } - if err := engine.Check(context.Background(), intent, decision.Decision{Outcome: decision.Constrain, Constraints: local.Constraints}); err != nil { - t.Fatalf("matching remote constraint rejected: %v", err) - } - intent.Resource = "unapproved:42" - denied, err := engine.Authorize(context.Background(), intent) - if err != nil || denied.Outcome != decision.Deny { - t.Fatalf("default deny result=%+v err=%v", denied, err) - } - if err := engine.Check(context.Background(), intent, decision.Decision{Outcome: decision.Allow}); err == nil { - t.Fatal("remote allow expanded local default deny") - } - if err := engine.Check(context.Background(), intent, decision.Decision{Outcome: decision.Deny}); err != nil { - t.Fatalf("remote deny should always be within ceiling: %v", err) - } -} - -func TestDisclosureRulesControlAuthorizationAndLocalCeiling(t *testing.T) { - now := time.Unix(1785500000, 0) - payload, err := json.Marshal(Document{ - Version: 1, DefaultOutcome: decision.Deny, - Rules: []Rule{{ - ID: "eu-finance-files", Agents: []string{"*"}, Actions: []string{"file.share"}, ResourcePrefixes: []string{"agent:finance/"}, - Risks: []decision.RiskClass{decision.RiskHigh}, Outcome: decision.Allow, - Disclosure: &DisclosureRule{ - LabelsAll: []string{"finance", "pii"}, ContentTypes: []string{"application/pdf"}, Recipients: []string{"agent:finance"}, - Purposes: []string{"invoice-payment"}, Residencies: []string{"eu-west-1"}, - }, - }}, - }) - if err != nil { - t.Fatal(err) - } - engine, _ := installedEngineWithPayload(t, now, payload) - disclosure := decision.DisclosureBinding{ - Version: decision.DisclosureBindingVersion, ContentHash: decision.HashPayload([]byte("invoice")), DeclaredBytes: 7, - ContentType: "application/pdf", Labels: []string{"finance", "pii"}, Recipient: "agent:finance", - Purpose: "invoice-payment", Residency: "eu-west-1", Filename: "invoice.pdf", - } - intent := disclosurePolicyIntent(t, now, disclosure) - plain, err := engine.Authorize(context.Background(), intent) - if err != nil || plain.Outcome != decision.Deny { - t.Fatalf("bare authorization=%+v err=%v", plain, err) - } - allowed, err := engine.AuthorizeDisclosure(context.Background(), intent, disclosure) - if err != nil || allowed.Outcome != decision.Allow { - t.Fatalf("typed authorization=%+v err=%v", allowed, err) - } - if err := engine.CheckDisclosure(context.Background(), intent, decision.Decision{Outcome: decision.Allow}, disclosure); err != nil { - t.Fatalf("matching local disclosure ceiling rejected allow: %v", err) - } - - usDisclosure := disclosure - usDisclosure.Residency = "us-east-1" - usIntent := disclosurePolicyIntent(t, now, usDisclosure) - denied, err := engine.AuthorizeDisclosure(context.Background(), usIntent, usDisclosure) - if err != nil || denied.Outcome != decision.Deny { - t.Fatalf("unapproved residency authorization=%+v err=%v", denied, err) - } - if err := engine.CheckDisclosure(context.Background(), usIntent, decision.Decision{Outcome: decision.Allow}, usDisclosure); err == nil || !strings.Contains(err.Error(), "expands local deny") { - t.Fatalf("unapproved residency remote allow accepted: %v", err) - } -} - -func TestDisclosureApprovalPlanUsesTheSameTypedRule(t *testing.T) { - now := time.Unix(1785500000, 0) - payload, err := json.Marshal(Document{ - Version: 1, DefaultOutcome: decision.Deny, - Rules: []Rule{{ - ID: "finance-approval", Agents: []string{"*"}, Actions: []string{"file.share"}, ResourcePrefixes: []string{"agent:finance/"}, Risks: []decision.RiskClass{decision.RiskHigh}, - Outcome: decision.ApprovalRequired, Disclosure: &DisclosureRule{LabelsAll: []string{"finance"}, Recipients: []string{"agent:finance"}, Purposes: []string{"invoice-payment"}, Residencies: []string{"eu-west-1"}}, - Approval: &ApprovalPlan{ApproverKeyIDs: []string{"approval-a", "approval-b"}, RequiredApprovals: 2, ValiditySeconds: 3600, Outcome: decision.Allow}, - }}, - }) - if err != nil { - t.Fatal(err) - } - engine, _ := installedEngineWithPayload(t, now, payload) - disclosure := decision.DisclosureBinding{ - Version: decision.DisclosureBindingVersion, ContentHash: decision.HashPayload([]byte("invoice")), DeclaredBytes: 7, - ContentType: "application/pdf", Labels: []string{"finance"}, Recipient: "agent:finance", Purpose: "invoice-payment", Residency: "eu-west-1", Filename: "invoice.pdf", - } - intent := disclosurePolicyIntent(t, now, disclosure) - initial, err := engine.AuthorizeDisclosure(context.Background(), intent, disclosure) - if err != nil || initial.Outcome != decision.ApprovalRequired { - t.Fatalf("typed approval result=%+v err=%v", initial, err) - } - plan, err := engine.ApprovalPlanForDisclosure(context.Background(), intent, initial, disclosure) - if err != nil || plan.RequiredApprovals != 2 || plan.Outcome != decision.Allow { - t.Fatalf("typed approval plan=%+v err=%v", plan, err) - } - if _, err := engine.ApprovalPlanFor(context.Background(), intent, initial); err == nil || !strings.Contains(err.Error(), "no long-running") { - t.Fatalf("bare approval planner selected disclosure plan: %v", err) - } -} - -func disclosurePolicyIntent(t *testing.T, now time.Time, disclosure decision.DisclosureBinding) decision.Intent { - t.Helper() - hash, err := disclosure.Hash() - if err != nil { - t.Fatal(err) - } - return decision.Intent{ - Version: decision.SchemaVersion, ID: "disclosure-policy-intent", TenantID: "tenant-a", AgentID: "agent-1", - Action: "file.share", Resource: "agent:finance/inbox", Audience: disclosure.Recipient, Purpose: disclosure.Purpose, - PayloadHash: hash, Risk: decision.RiskHigh, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Minute).Unix(), - Nonce: strings.Repeat("d", 32), KeyID: "agent-key-1", - } -} - -func TestCompileRejectsUnsafeOrAmbiguousDocuments(t *testing.T) { - t.Parallel() - unsafe := []string{ - `{"version":1,"default_outcome":"allow","rules":[]}`, - `{"version":1,"default_outcome":"deny","rules":[],"unknown":true}`, - `{"version":1,"default_outcome":"deny","rules":[{"id":"x","agents":["*"],"actions":["wallet.*"],"resource_prefixes":["*"],"risks":["high"],"outcome":"constrain"}]}`, - `{"version":1,"default_outcome":"deny","rules":[{"id":"x","agents":["*"],"actions":["wallet.**"],"resource_prefixes":["*"],"risks":["high"],"outcome":"allow"}]}`, - `{"version":1,"default_outcome":"deny","rules":[{"id":"x","agents":["*"],"actions":["wallet.*"],"resource_prefixes":["*"],"risks":["high"],"outcome":"allow","approval":{"approver_key_ids":["a"],"required_approvals":1,"validity_seconds":60,"outcome":"allow"}}]}`, - `{"version":1,"default_outcome":"deny","rules":[{"id":"x","agents":["*"],"actions":["wallet.*"],"resource_prefixes":["*"],"risks":["high"],"outcome":"approval_required","approval":{"approver_key_ids":["a"],"required_approvals":1,"validity_seconds":0,"outcome":"allow"}}]}`, - `{"version":1,"default_outcome":"deny","rules":[{"id":"x","agents":["*"],"actions":["file.share"],"resource_prefixes":["*"],"risks":["high"],"outcome":"allow","disclosure":{}}]}`, - `{"version":1,"default_outcome":"deny","rules":[{"id":"x","agents":["*"],"actions":["file.share"],"resource_prefixes":["*"],"risks":["high"],"outcome":"allow","disclosure":{"labels_all":["PII"]}}]}`, - } - for _, payload := range unsafe { - if _, err := Compile([]byte(payload)); err == nil { - t.Errorf("unsafe policy accepted: %s", payload) - } - } -} diff --git a/internal/managedsdk/decisionpolicy/retention_test.go b/internal/managedsdk/decisionpolicy/retention_test.go deleted file mode 100644 index a179a449..00000000 --- a/internal/managedsdk/decisionpolicy/retention_test.go +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decisionpolicy - -import ( - "testing" - "time" - - "github.com/pilot-protocol/common/decision" -) - -func TestRetentionClassIsAPolicyEnforcedDisclosureField(t *testing.T) { - rule := Rule{ - ID: "retained-finance", Agents: []string{"agent-1"}, Actions: []string{"file.share"}, ResourcePrefixes: []string{"agent:finance/"}, - Risks: []decision.RiskClass{decision.RiskHigh}, Outcome: decision.Allow, - Disclosure: &DisclosureRule{RetentionClasses: []string{"finance-7y"}}, - } - payload := encodeDocument(t, rule) - policy, err := Compile(payload) - if err != nil { - t.Fatal(err) - } - disclosure := decision.DisclosureBinding{ - Version: decision.DisclosureBindingRetentionVersion, ContentHash: decision.HashPayload([]byte("invoice")), DeclaredBytes: 7, - ContentType: "application/pdf", Labels: []string{"finance"}, Recipient: "agent:finance", Purpose: "invoice-payment", Residency: "eu-west-1", Filename: "invoice.pdf", RetentionClass: "finance-7y", - } - intent := disclosurePolicyIntent(t, time.Unix(1785500000, 0), disclosure) - if got := policy.evaluateDisclosure(intent, disclosure); got.Outcome != decision.Allow { - t.Fatalf("retention class allow=%+v", got) - } - disclosure.RetentionClass = "finance-30d" - if got := policy.evaluateDisclosure(intent, disclosure); got.Outcome != decision.Deny { - t.Fatalf("retention class mismatch=%+v", got) - } -} diff --git a/internal/managedsdk/decisionpolicy/rollout_simulator.go b/internal/managedsdk/decisionpolicy/rollout_simulator.go deleted file mode 100644 index e035c54c..00000000 --- a/internal/managedsdk/decisionpolicy/rollout_simulator.go +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decisionpolicy - -import ( - "context" - "fmt" - - "github.com/pilot-protocol/common/decision" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" -) - -type RolloutValidation interface { - ValidateCandidate(context.Context, authority.PolicyPublication, authority.PolicyBundle) error - CurrentPolicy(context.Context, string) (authority.PolicyBundle, error) -} - -type RolloutSimulation struct { - CurrentRevision uint64 `json:"current_revision"` - CandidateRevision uint64 `json:"candidate_revision"` - Comparison Comparison `json:"comparison"` -} - -type RolloutSimulator struct{ Rollouts RolloutValidation } - -func (simulator RolloutSimulator) Simulate(ctx context.Context, publication authority.PolicyPublication, bundle authority.PolicyBundle, intents []decision.Intent) (RolloutSimulation, error) { - inputs := make([]SimulationInput, len(intents)) - for index, intent := range intents { - inputs[index] = SimulationInput{Intent: intent} - } - return simulator.SimulateInputs(ctx, publication, bundle, inputs) -} - -// SimulateInputs compares a candidate against the active signed policy with -// both legacy Intent and hash-bound typed disclosure cases. -func (simulator RolloutSimulator) SimulateInputs(ctx context.Context, publication authority.PolicyPublication, bundle authority.PolicyBundle, inputs []SimulationInput) (RolloutSimulation, error) { - if simulator.Rollouts == nil { - return RolloutSimulation{}, fmt.Errorf("decisionpolicy: rollout validator is required") - } - if err := simulator.Rollouts.ValidateCandidate(ctx, publication, bundle); err != nil { - return RolloutSimulation{}, err - } - current, err := simulator.Rollouts.CurrentPolicy(ctx, bundle.TenantID) - if err != nil { - return RolloutSimulation{}, err - } - comparison, err := CompareInputs(current.Payload, bundle.Payload, inputs) - if err != nil { - return RolloutSimulation{}, err - } - return RolloutSimulation{ - CurrentRevision: current.Revision, CandidateRevision: bundle.Revision, Comparison: comparison, - }, nil -} diff --git a/internal/managedsdk/decisionpolicy/simulation.go b/internal/managedsdk/decisionpolicy/simulation.go deleted file mode 100644 index c6dc0938..00000000 --- a/internal/managedsdk/decisionpolicy/simulation.go +++ /dev/null @@ -1,259 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decisionpolicy - -import ( - "bytes" - "encoding/json" - "fmt" - "sort" - - "github.com/pilot-protocol/common/decision" -) - -const MaxSimulationIntents = 1_000 - -// SimulationInput is a policy-review case. Disclosure is optional for legacy -// V1 intents, but when present it must be the exact metadata bound by the -// input Intent. This lets policy rollout simulation show the real effect of -// typed data-governance rules before an operator activates them. -type SimulationInput struct { - Intent decision.Intent `json:"intent"` - Disclosure *decision.DisclosureBinding `json:"disclosure,omitempty"` -} - -type SimulationResult struct { - IntentID string `json:"intent_id"` - Outcome decision.Outcome `json:"outcome"` - Reasons []string `json:"reasons,omitempty"` - Constraints []decision.Constraint `json:"constraints,omitempty"` - Approval *ApprovalPlan `json:"approval,omitempty"` -} - -type DocumentDiff struct { - DefaultOutcomeFrom decision.Outcome `json:"default_outcome_from"` - DefaultOutcomeTo decision.Outcome `json:"default_outcome_to"` - AddedRules []string `json:"added_rules"` - RemovedRules []string `json:"removed_rules"` - ChangedRules []string `json:"changed_rules"` - Changed bool `json:"changed"` -} - -type ImpactChange struct { - IntentID string `json:"intent_id"` - Before decision.Outcome `json:"before"` - After decision.Outcome `json:"after"` - BeforeConstraints []decision.Constraint `json:"before_constraints,omitempty"` - AfterConstraints []decision.Constraint `json:"after_constraints,omitempty"` - BeforeApproval *ApprovalPlan `json:"before_approval,omitempty"` - AfterApproval *ApprovalPlan `json:"after_approval,omitempty"` - AuthorityChange string `json:"authority_change"` -} - -type Comparison struct { - Diff DocumentDiff `json:"diff"` - Candidate []SimulationResult `json:"candidate"` - Impacts []ImpactChange `json:"impacts"` -} - -func Simulate(payload []byte, intents []decision.Intent) ([]SimulationResult, error) { - inputs := make([]SimulationInput, len(intents)) - for index, intent := range intents { - inputs[index].Intent = intent - } - return SimulateInputs(payload, inputs) -} - -func SimulateInputs(payload []byte, inputs []SimulationInput) ([]SimulationResult, error) { - if len(inputs) > MaxSimulationIntents { - return nil, fmt.Errorf("decisionpolicy: at most %d simulation inputs are allowed", MaxSimulationIntents) - } - policy, err := Compile(payload) - if err != nil { - return nil, err - } - results := make([]SimulationResult, 0, len(inputs)) - for _, input := range inputs { - if err := input.Validate(); err != nil { - return nil, fmt.Errorf("decisionpolicy: invalid simulation intent %q: %w", input.Intent.ID, err) - } - result, approval := policy.evaluateInput(input) - results = append(results, SimulationResult{ - IntentID: input.Intent.ID, Outcome: result.Outcome, - Reasons: append([]string(nil), result.Reasons...), - Constraints: append([]decision.Constraint(nil), result.Constraints...), - Approval: approval, - }) - } - return results, nil -} - -func (input SimulationInput) Validate() error { - if err := input.Intent.Validate(); err != nil { - return err - } - if input.Disclosure != nil { - return input.Disclosure.VerifyIntent(input.Intent) - } - return nil -} - -func (policy *compiled) evaluateInput(input SimulationInput) (decision.Decision, *ApprovalPlan) { - if input.Disclosure != nil { - return policy.evaluateDisclosure(input.Intent, *input.Disclosure), policy.approvalPlanDisclosure(input.Intent, *input.Disclosure) - } - return policy.evaluate(input.Intent), policy.approvalPlan(input.Intent) -} - -func (policy *compiled) approvalPlanDisclosure(intent decision.Intent, disclosure decision.DisclosureBinding) *ApprovalPlan { - for _, rule := range policy.document.Rules { - if matches(rule, intent, &disclosure) { - if rule.Outcome != decision.ApprovalRequired || rule.Approval == nil { - return nil - } - plan := cloneApprovalPlan(*rule.Approval) - return &plan - } - } - return nil -} - -func Compare(currentPayload, candidatePayload []byte, intents []decision.Intent) (Comparison, error) { - inputs := make([]SimulationInput, len(intents)) - for index, intent := range intents { - inputs[index] = SimulationInput{Intent: intent} - } - return CompareInputs(currentPayload, candidatePayload, inputs) -} - -func CompareInputs(currentPayload, candidatePayload []byte, inputs []SimulationInput) (Comparison, error) { - current, err := Compile(currentPayload) - if err != nil { - return Comparison{}, fmt.Errorf("decisionpolicy: current policy: %w", err) - } - candidate, err := Compile(candidatePayload) - if err != nil { - return Comparison{}, fmt.Errorf("decisionpolicy: candidate policy: %w", err) - } - if len(inputs) > MaxSimulationIntents { - return Comparison{}, fmt.Errorf("decisionpolicy: at most %d simulation intents are allowed", MaxSimulationIntents) - } - comparison := Comparison{Diff: diffDocuments(current.document, candidate.document)} - for _, input := range inputs { - if err := input.Validate(); err != nil { - return Comparison{}, fmt.Errorf("decisionpolicy: invalid simulation intent %q: %w", input.Intent.ID, err) - } - before, beforeApproval := current.evaluateInput(input) - after, afterApproval := candidate.evaluateInput(input) - comparison.Candidate = append(comparison.Candidate, SimulationResult{ - IntentID: input.Intent.ID, Outcome: after.Outcome, - Reasons: append([]string(nil), after.Reasons...), Constraints: append([]decision.Constraint(nil), after.Constraints...), Approval: afterApproval, - }) - if before.Outcome != after.Outcome || !constraintSetsEqual(before.Constraints, after.Constraints) || !approvalPlansEqual(beforeApproval, afterApproval) { - authorityChange := classifyChange(before.Outcome, after.Outcome) - if before.Outcome == decision.ApprovalRequired && after.Outcome == decision.ApprovalRequired && !approvalPlansEqual(beforeApproval, afterApproval) { - authorityChange = "approval_changed" - } - comparison.Impacts = append(comparison.Impacts, ImpactChange{ - IntentID: input.Intent.ID, Before: before.Outcome, After: after.Outcome, - BeforeConstraints: append([]decision.Constraint(nil), before.Constraints...), - AfterConstraints: append([]decision.Constraint(nil), after.Constraints...), - BeforeApproval: beforeApproval, AfterApproval: afterApproval, AuthorityChange: authorityChange, - }) - } - } - return comparison, nil -} - -func approvalPlansEqual(first, second *ApprovalPlan) bool { - if first == nil || second == nil { - return first == nil && second == nil - } - left := cloneApprovalPlan(*first) - right := cloneApprovalPlan(*second) - canonicalizeApprovalPlan(&left) - canonicalizeApprovalPlan(&right) - firstJSON, firstErr := json.Marshal(left) - secondJSON, secondErr := json.Marshal(right) - return firstErr == nil && secondErr == nil && bytes.Equal(firstJSON, secondJSON) -} - -func canonicalizeApprovalPlan(plan *ApprovalPlan) { - if plan == nil { - return - } - sort.Strings(plan.ApproverKeyIDs) - sort.Slice(plan.Constraints, func(i, j int) bool { - if plan.Constraints[i].Key != plan.Constraints[j].Key { - return plan.Constraints[i].Key < plan.Constraints[j].Key - } - if plan.Constraints[i].Operator != plan.Constraints[j].Operator { - return plan.Constraints[i].Operator < plan.Constraints[j].Operator - } - return plan.Constraints[i].Value < plan.Constraints[j].Value - }) -} - -func diffDocuments(current, candidate Document) DocumentDiff { - diff := DocumentDiff{DefaultOutcomeFrom: current.DefaultOutcome, DefaultOutcomeTo: candidate.DefaultOutcome} - currentRules := make(map[string]Rule, len(current.Rules)) - candidateRules := make(map[string]Rule, len(candidate.Rules)) - for _, rule := range current.Rules { - currentRules[rule.ID] = rule - } - for _, rule := range candidate.Rules { - candidateRules[rule.ID] = rule - } - for id, currentRule := range currentRules { - candidateRule, exists := candidateRules[id] - if !exists { - diff.RemovedRules = append(diff.RemovedRules, id) - continue - } - currentJSON, _ := json.Marshal(currentRule) - candidateJSON, _ := json.Marshal(candidateRule) - if !bytes.Equal(currentJSON, candidateJSON) { - diff.ChangedRules = append(diff.ChangedRules, id) - } - } - for id := range candidateRules { - if _, exists := currentRules[id]; !exists { - diff.AddedRules = append(diff.AddedRules, id) - } - } - sort.Strings(diff.AddedRules) - sort.Strings(diff.RemovedRules) - sort.Strings(diff.ChangedRules) - diff.Changed = diff.DefaultOutcomeFrom != diff.DefaultOutcomeTo || len(diff.AddedRules)+len(diff.RemovedRules)+len(diff.ChangedRules) > 0 - return diff -} - -func classifyChange(before, after decision.Outcome) string { - rank := map[decision.Outcome]int{ - decision.Deny: 0, decision.ApprovalRequired: 1, decision.Constrain: 2, decision.Allow: 3, - } - if rank[after] > rank[before] { - return "expanded" - } - if rank[after] < rank[before] { - return "narrowed" - } - return "constraints_changed" -} - -func constraintSetsEqual(first, second []decision.Constraint) bool { - if len(first) != len(second) { - return false - } - counts := make(map[decision.Constraint]int, len(first)) - for _, constraint := range first { - counts[constraint]++ - } - for _, constraint := range second { - if counts[constraint] == 0 { - return false - } - counts[constraint]-- - } - return true -} diff --git a/internal/managedsdk/decisionpolicy/simulation_test.go b/internal/managedsdk/decisionpolicy/simulation_test.go deleted file mode 100644 index 9f13de49..00000000 --- a/internal/managedsdk/decisionpolicy/simulation_test.go +++ /dev/null @@ -1,175 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -package decisionpolicy - -import ( - "context" - "encoding/json" - "strings" - "testing" - "time" - - "github.com/pilot-protocol/common/decision" - "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" -) - -func simulationIntent(id, action, resource string) decision.Intent { - return decision.Intent{ - Version: decision.SchemaVersion, ID: id, TenantID: "tenant-a", AgentID: "agent-1", - Action: action, Resource: resource, PayloadHash: decision.HashPayload([]byte(id)), - Risk: decision.RiskHigh, IssuedAt: time.Unix(1785500000, 0).Unix(), - ExpiresAt: time.Unix(1785500000, 0).Add(time.Minute).Unix(), - Nonce: strings.Repeat("0", 32), KeyID: "agent-key-1", - } -} - -type rolloutValidationMock struct { - current authority.PolicyBundle - err error -} - -func (mock rolloutValidationMock) ValidateCandidate(context.Context, authority.PolicyPublication, authority.PolicyBundle) error { - return mock.err -} - -func (mock rolloutValidationMock) CurrentPolicy(context.Context, string) (authority.PolicyBundle, error) { - return mock.current, mock.err -} - -func encodeDocument(t *testing.T, rules ...Rule) []byte { - t.Helper() - body, err := json.Marshal(Document{Version: 1, DefaultOutcome: decision.Deny, Rules: rules}) - if err != nil { - t.Fatal(err) - } - return body -} - -func TestCompareReportsSemanticDiffAndAuthorityImpact(t *testing.T) { - t.Parallel() - current := encodeDocument(t, Rule{ - ID: "wallet", Agents: []string{"agent-1"}, Actions: []string{"wallet.*"}, - ResourcePrefixes: []string{"merchant:"}, Risks: []decision.RiskClass{decision.RiskHigh}, Outcome: decision.Allow, - }) - candidate := encodeDocument(t, - Rule{ - ID: "wallet", Agents: []string{"agent-1"}, Actions: []string{"wallet.*"}, - ResourcePrefixes: []string{"merchant:"}, Risks: []decision.RiskClass{decision.RiskHigh}, - Outcome: decision.Constrain, Constraints: []decision.Constraint{{Key: "amount", Operator: "max", Value: "100"}}, - }, - Rule{ - ID: "admin-deny", Agents: []string{"*"}, Actions: []string{"admin.*"}, - ResourcePrefixes: []string{"*"}, Risks: []decision.RiskClass{decision.RiskHigh}, Outcome: decision.Deny, - }, - ) - comparison, err := Compare(current, candidate, []decision.Intent{ - simulationIntent("wallet-intent", "wallet.pay", "merchant:42"), - simulationIntent("other-intent", "message.send", "agent:2"), - }) - if err != nil { - t.Fatal(err) - } - if !comparison.Diff.Changed || len(comparison.Diff.ChangedRules) != 1 || comparison.Diff.ChangedRules[0] != "wallet" || - len(comparison.Diff.AddedRules) != 1 || comparison.Diff.AddedRules[0] != "admin-deny" { - t.Fatalf("unexpected diff: %+v", comparison.Diff) - } - if len(comparison.Impacts) != 1 || comparison.Impacts[0].IntentID != "wallet-intent" || - comparison.Impacts[0].AuthorityChange != "narrowed" || comparison.Impacts[0].After != decision.Constrain { - t.Fatalf("unexpected impacts: %+v", comparison.Impacts) - } -} - -func TestCompareInputsEvaluatesTypedDisclosureRules(t *testing.T) { - t.Parallel() - current := encodeDocument(t, Rule{ - ID: "eu-finance", Agents: []string{"agent-1"}, Actions: []string{"file.share"}, ResourcePrefixes: []string{"agent:finance/"}, - Risks: []decision.RiskClass{decision.RiskHigh}, Outcome: decision.Allow, - Disclosure: &DisclosureRule{LabelsAll: []string{"finance", "pii"}, ContentTypes: []string{"application/pdf"}, Recipients: []string{"agent:finance"}, Purposes: []string{"invoice-payment"}, Residencies: []string{"eu-west-1"}}, - }) - candidate := encodeDocument(t, Rule{ - ID: "eu-finance", Agents: []string{"agent-1"}, Actions: []string{"file.share"}, ResourcePrefixes: []string{"agent:finance/"}, - Risks: []decision.RiskClass{decision.RiskHigh}, Outcome: decision.Allow, - Disclosure: &DisclosureRule{LabelsAll: []string{"finance", "pii"}, ContentTypes: []string{"application/pdf"}, Recipients: []string{"agent:finance"}, Purposes: []string{"invoice-payment"}, Residencies: []string{"eu-central-1"}}, - }) - disclosure := decision.DisclosureBinding{ - Version: decision.DisclosureBindingVersion, ContentHash: decision.HashPayload([]byte("invoice")), DeclaredBytes: 7, - ContentType: "application/pdf", Labels: []string{"finance", "pii"}, Recipient: "agent:finance", Purpose: "invoice-payment", Residency: "eu-west-1", Filename: "invoice.pdf", - } - hash, err := disclosure.Hash() - if err != nil { - t.Fatal(err) - } - input := SimulationInput{Intent: decision.Intent{ - Version: decision.SchemaVersion, ID: "finance-disclosure", TenantID: "tenant-a", AgentID: "agent-1", Action: "file.share", Resource: "agent:finance/inbox", - Audience: disclosure.Recipient, Purpose: disclosure.Purpose, PayloadHash: hash, Risk: decision.RiskHigh, - IssuedAt: time.Unix(1785500000, 0).Unix(), ExpiresAt: time.Unix(1785500000, 0).Add(time.Minute).Unix(), Nonce: strings.Repeat("b", 32), KeyID: "agent-key-1", - }, Disclosure: &disclosure} - comparison, err := CompareInputs(current, candidate, []SimulationInput{input}) - if err != nil { - t.Fatal(err) - } - if len(comparison.Impacts) != 1 || comparison.Impacts[0].Before != decision.Allow || comparison.Impacts[0].After != decision.Deny || comparison.Impacts[0].AuthorityChange != "narrowed" { - t.Fatalf("typed disclosure impact=%+v", comparison) - } - bad := input - bad.Disclosure = &decision.DisclosureBinding{Version: decision.DisclosureBindingVersion, ContentHash: disclosure.ContentHash, DeclaredBytes: disclosure.DeclaredBytes, ContentType: disclosure.ContentType, Labels: append([]string(nil), disclosure.Labels...), Recipient: disclosure.Recipient, Purpose: disclosure.Purpose, Residency: "us-east-1", Filename: disclosure.Filename} - if _, err := CompareInputs(current, candidate, []SimulationInput{bad}); err == nil || !strings.Contains(err.Error(), "does not bind") { - t.Fatalf("unbound disclosure simulation accepted: %v", err) - } -} - -func TestCompareReportsApprovalPlanChangeAsAnImpact(t *testing.T) { - t.Parallel() - current := encodeDocument(t, Rule{ - ID: "message-approval", Agents: []string{"agent-1"}, Actions: []string{"message.send"}, ResourcePrefixes: []string{"agent:"}, - Risks: []decision.RiskClass{decision.RiskHigh}, Outcome: decision.ApprovalRequired, - Approval: &ApprovalPlan{ApproverKeyIDs: []string{"approval-a"}, RequiredApprovals: 1, ValiditySeconds: 3600, Outcome: decision.Allow}, - }) - candidate := encodeDocument(t, Rule{ - ID: "message-approval", Agents: []string{"agent-1"}, Actions: []string{"message.send"}, ResourcePrefixes: []string{"agent:"}, - Risks: []decision.RiskClass{decision.RiskHigh}, Outcome: decision.ApprovalRequired, - Approval: &ApprovalPlan{ApproverKeyIDs: []string{"approval-a", "approval-b"}, RequiredApprovals: 2, ValiditySeconds: 7200, Outcome: decision.Constrain, Constraints: []decision.Constraint{{Key: "recipient", Operator: "eq", Value: "agent:finance"}}}, - }) - comparison, err := Compare(current, candidate, []decision.Intent{simulationIntent("message-intent", "message.send", "agent:finance")}) - if err != nil { - t.Fatal(err) - } - if len(comparison.Impacts) != 1 || comparison.Impacts[0].AuthorityChange != "approval_changed" || comparison.Impacts[0].BeforeApproval == nil || comparison.Impacts[0].AfterApproval == nil || comparison.Impacts[0].AfterApproval.RequiredApprovals != 2 { - t.Fatalf("approval plan impact=%+v", comparison.Impacts) - } -} - -func TestSimulationRejectsMalformedCandidateAndOversizedCorpus(t *testing.T) { - t.Parallel() - current := encodeDocument(t, Rule{ - ID: "deny", Agents: []string{"*"}, Actions: []string{"wallet.*"}, ResourcePrefixes: []string{"*"}, - Risks: []decision.RiskClass{decision.RiskHigh}, Outcome: decision.Deny, - }) - if _, err := Compare(current, []byte(`{"version":1,"default_outcome":"allow","rules":[]}`), nil); err == nil { - t.Fatal("unsafe candidate was simulated") - } - intents := make([]decision.Intent, MaxSimulationIntents+1) - if _, err := Simulate(current, intents); err == nil { - t.Fatal("oversized simulation corpus was accepted") - } -} - -func TestRolloutSimulatorValidatesSignedEnvelopeBeforeComparison(t *testing.T) { - t.Parallel() - current := encodeDocument(t, Rule{ - ID: "wallet", Agents: []string{"agent-1"}, Actions: []string{"wallet.*"}, ResourcePrefixes: []string{"*"}, - Risks: []decision.RiskClass{decision.RiskHigh}, Outcome: decision.Allow, - }) - candidate := encodeDocument(t, Rule{ - ID: "wallet", Agents: []string{"agent-1"}, Actions: []string{"wallet.*"}, ResourcePrefixes: []string{"*"}, - Risks: []decision.RiskClass{decision.RiskHigh}, Outcome: decision.Deny, - }) - simulator := RolloutSimulator{Rollouts: rolloutValidationMock{current: authority.PolicyBundle{Revision: 1, Payload: current}}} - result, err := simulator.Simulate(context.Background(), authority.PolicyPublication{}, authority.PolicyBundle{TenantID: "tenant-a", Revision: 2, Payload: candidate}, []decision.Intent{simulationIntent("wallet-intent", "wallet.pay", "merchant:1")}) - if err != nil { - t.Fatal(err) - } - if result.CurrentRevision != 1 || result.CandidateRevision != 2 || len(result.Comparison.Impacts) != 1 || result.Comparison.Impacts[0].AuthorityChange != "narrowed" { - t.Fatalf("unexpected rollout simulation: %+v", result) - } -}