From a966264bb1a55156ae42d8fdcc3c1075d649b1b1 Mon Sep 17 00:00:00 2001 From: Pulse Test Date: Sat, 29 Aug 2026 22:12:41 +0100 Subject: [PATCH] Contain agent command authority --- cmd/pulse-agent/main.go | 99 +++++++++---- cmd/pulse-agent/main_test.go | 58 ++++++++ docs/AGENT_SECURITY.md | 20 ++- docs/PRODUCTION_SECURITY.md | 12 +- .../v6/internal/subsystems/agent-lifecycle.md | 11 ++ .../v6/internal/subsystems/api-contracts.md | 8 + .../subsystems/deployment-installability.md | 10 ++ .../v6/internal/subsystems/monitoring.md | 8 + .../internal/subsystems/security-privacy.md | 8 + .../internal/subsystems/storage-recovery.md | 4 + frontend-modern/browser-verification.json | 56 +++---- frontend-modern/src/api/agentDiagnostics.ts | 3 + .../InfrastructureInstallerSection.tsx | 23 +-- .../Settings/NodeModalSetupGuideSection.tsx | 20 +-- .../infrastructureAgentDoctorModel.test.ts | 22 +++ .../__tests__/settingsArchitecture.test.ts | 2 +- .../infrastructureAgentUpdateCommandsModel.ts | 12 +- .../components/Settings/useNodeModalState.ts | 2 +- internal/api/agent_install_command_shared.go | 4 +- .../api/agent_install_command_shared_test.go | 28 ++++ internal/api/agenttokens/install.go | 14 +- internal/api/agenttokens/install_test.go | 23 +++ internal/api/cloud_agent_install_command.go | 10 +- .../api/configapi/config_setup_handlers.go | 1 + internal/api/configapi/dependencies.go | 4 + internal/api/contract_test.go | 24 +++ .../api/hosted_agent_install_command_test.go | 4 +- internal/api/security_regression_test.go | 15 ++ internal/hostagent/agent.go | 137 +++++++++++------- internal/hostagent/agent_new_test.go | 40 ++++- internal/hostagent/command_authority.go | 50 +++++++ internal/hostagent/command_authority_test.go | 45 ++++++ internal/hostagent/hostagent_coverage_test.go | 18 ++- internal/hostagent/privilege.go | 9 +- internal/hostagent/privilege_test.go | 7 +- internal/models/models.go | 9 +- internal/models/state_host_test.go | 19 +++ internal/monitoring/agent_fleet_doctor.go | 59 ++++++-- .../monitoring/agent_fleet_doctor_test.go | 72 ++++++++- internal/monitoring/monitor_agents.go | 9 +- pkg/agents/host/report.go | 5 +- pkg/agents/host/report_test.go | 10 +- scripts/install.ps1 | 34 +++++ scripts/install.sh | 67 +++++++-- scripts/installtests/install_ps1_test.go | 5 + scripts/installtests/install_sh_test.go | 72 +++++++-- 46 files changed, 935 insertions(+), 237 deletions(-) create mode 100644 internal/hostagent/command_authority.go create mode 100644 internal/hostagent/command_authority_test.go diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index 969d290cb4..a60db3f711 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -278,18 +278,27 @@ func run(ctx context.Context, args []string, getenv func(string) string) error { Str("component", "remote_config"). Str("action", "fetch_succeeded"). Msg("Successfully fetched remote configuration") + commandAuthorityApplied := true if commandsEnabled != nil { - cfg.EnableCommands = *commandsEnabled + commandAuthorityApplied = applyInitialRemoteCommandAuthority(&cfg, commandsEnabled) logger.Info(). Str("component", "remote_config"). Str("action", "apply_enable_commands"). + Str("local_authority", string(cfg.CommandAuthorityProfile)). + Bool("accepted", commandAuthorityApplied). Bool("enabled", cfg.EnableCommands). Msg("Applied remote command execution setting") + if !commandAuthorityApplied { + logger.Warn(). + Str("component", "remote_config"). + Str("action", "reject_enable_commands"). + Msg("Ignored remote command enablement because the local runtime is monitoring-only") + } } if len(settings) > 0 { applyRemoteSettings(&cfg, settings, &logger) } - if remoteconfig.HasAppliedDesiredConfig(commandsEnabled, settings) { + if commandAuthorityApplied && remoteconfig.HasAppliedDesiredConfig(commandsEnabled, settings) { metadata, metadataErr := remoteconfig.BuildDesiredConfigMetadata(commandsEnabled, settings) if metadataErr != nil { logger.Warn().Err(metadataErr).Msg("Failed to derive applied managed configuration fingerprint") @@ -397,34 +406,35 @@ func run(ctx context.Context, args []string, getenv func(string) string) error { // 8. Start Host Agent (if enabled) if cfg.EnableHost { hostCfg := hostagent.Config{ - PulseURL: cfg.PulseURL, - APIToken: cfg.APIToken, - Interval: cfg.Interval, - HostnameOverride: cfg.HostnameOverride, - AgentID: cfg.AgentID, - AgentType: "unified", - AgentVersion: Version, - Tags: cfg.Tags, - InsecureSkipVerify: cfg.InsecureSkipVerify, - CACertPath: cfg.CACertPath, - ServerFingerprint: cfg.ServerFingerprint, - CustomSensorsFile: cfg.CustomSensorsFile, - DeploySSHUser: cfg.DeploySSHUser, - LogLevel: cfg.LogLevel, - Logger: &logger, - EnableProxmox: cfg.EnableProxmox, - ProxmoxType: cfg.ProxmoxType, - EnableCommands: cfg.EnableCommands, - Enroll: cfg.Enroll, - DiskExclude: cfg.DiskExclude, - DiskInclude: cfg.DiskInclude, - StateDir: cfg.StateDir, - ReportIP: cfg.ReportIP, - DisableCeph: cfg.DisableCeph, - AvailabilityTargets: cfg.AvailabilityTargets, - AppliedConfig: cfg.AppliedConfig, - ModuleStatus: runtimeStatus.moduleStatuses, - Observers: hostObserverTargets(cfg.Observers), + PulseURL: cfg.PulseURL, + APIToken: cfg.APIToken, + Interval: cfg.Interval, + HostnameOverride: cfg.HostnameOverride, + AgentID: cfg.AgentID, + AgentType: "unified", + AgentVersion: Version, + Tags: cfg.Tags, + InsecureSkipVerify: cfg.InsecureSkipVerify, + CACertPath: cfg.CACertPath, + ServerFingerprint: cfg.ServerFingerprint, + CustomSensorsFile: cfg.CustomSensorsFile, + DeploySSHUser: cfg.DeploySSHUser, + LogLevel: cfg.LogLevel, + Logger: &logger, + EnableProxmox: cfg.EnableProxmox, + ProxmoxType: cfg.ProxmoxType, + EnableCommands: cfg.EnableCommands, + CommandAuthorityProfile: cfg.CommandAuthorityProfile, + Enroll: cfg.Enroll, + DiskExclude: cfg.DiskExclude, + DiskInclude: cfg.DiskInclude, + StateDir: cfg.StateDir, + ReportIP: cfg.ReportIP, + DisableCeph: cfg.DisableCeph, + AvailabilityTargets: cfg.AvailabilityTargets, + AppliedConfig: cfg.AppliedConfig, + ModuleStatus: runtimeStatus.moduleStatuses, + Observers: hostObserverTargets(cfg.Observers), DockerContainerUpdater: dockerUpdaterBridge, DockerContainerLifecycleOperator: dockerUpdaterBridge, @@ -677,6 +687,15 @@ func writeAgentIDFile(path, id string) error { return nil } +func applyInitialRemoteCommandAuthority(cfg *Config, desired *bool) bool { + if cfg == nil || desired == nil { + return true + } + effective, accepted := hostagent.ResolveCommandAuthority(cfg.CommandAuthorityProfile, *desired) + cfg.EnableCommands = effective + return accepted +} + func runRemoteConfigLoop(ctx context.Context, client *remoteconfig.Client, appliers []RemoteConfigApplier, logger *zerolog.Logger) { if client == nil || len(appliers) == 0 { return @@ -850,7 +869,8 @@ type Config struct { DockerRuntime string // Force Docker / Podman runtime: docker, podman, or auto // Security - EnableCommands bool // Enable Pulse command execution for Patrol actions and Proxmox LXC Docker inventory (disabled by default) + EnableCommands bool // Enable Pulse command execution for Patrol actions and Proxmox LXC Docker inventory (disabled by default) + CommandAuthorityProfile hostagent.CommandAuthorityProfile // Immutable local authority ceiling; empty/unmarked is legacy-compatible. // Enrollment Enroll bool // Exchange bootstrap token for runtime token on startup @@ -980,6 +1000,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { envDisableRegistryCredentials := strings.TrimSpace(getenv("PULSE_DISABLE_REGISTRY_CREDENTIALS")) envDockerRuntime := strings.TrimSpace(getenv("PULSE_DOCKER_RUNTIME")) envEnableCommands := strings.TrimSpace(getenv("PULSE_ENABLE_COMMANDS")) + envCommandAuthority := strings.TrimSpace(getenv("PULSE_COMMAND_AUTHORITY")) envDisableCommands := strings.TrimSpace(getenv("PULSE_DISABLE_COMMANDS")) // deprecated envHealthAddr := strings.TrimSpace(getenv("PULSE_HEALTH_ADDR")) envKubeconfig := strings.TrimSpace(getenv("PULSE_KUBECONFIG")) @@ -1069,6 +1090,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { disableRegistryCredentialsFlag := fs.Bool("disable-registry-credentials", utils.ParseBool(envDisableRegistryCredentials), "Do not read host Docker credentials (config.json / credential helpers) for registry update checks") dockerRuntimeFlag := fs.String("docker-runtime", envDockerRuntime, "Docker / Podman runtime: auto, docker, or podman (default: auto)") enableCommandsFlag := fs.Bool("enable-commands", utils.ParseBool(envEnableCommands), "Enable Pulse command execution for Patrol actions and Proxmox LXC Docker inventory (disabled by default)") + commandAuthorityFlag := fs.String("command-authority", envCommandAuthority, "Local command authority: monitoring-only, command-capable, or legacy") disableCommandsFlag := fs.Bool("disable-commands", false, "[DEPRECATED] Commands are now disabled by default; use --enable-commands to enable") enrollFlag := fs.Bool("enroll", false, "Exchange bootstrap token for runtime token (used by deploy wizard)") healthAddrFlag := fs.String("health-addr", defaultHealthAddr, "Health/metrics server address (empty to disable)") @@ -1167,6 +1189,18 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { if err != nil { return Config{}, err } + enableCommands := resolveEnableCommands(*enableCommandsFlag, *disableCommandsFlag, envEnableCommands, envDisableCommands) + commandAuthorityRaw := strings.TrimSpace(*commandAuthorityFlag) + if commandAuthorityRaw == "" && enableCommands { + commandAuthorityRaw = string(hostagent.CommandAuthorityCommandCapable) + } + commandAuthorityProfile, err := hostagent.NormalizeCommandAuthorityProfile(commandAuthorityRaw) + if err != nil { + return Config{}, err + } + if commandAuthorityProfile == hostagent.CommandAuthorityMonitoringOnly && enableCommands { + return Config{}, fmt.Errorf("--enable-commands conflicts with --command-authority monitoring-only") + } tags := gatherTags(envTags, tagFlags) kubeIncludeNamespaces := gatherCSV(envKubeIncludeNamespaces, kubeIncludeNamespaceFlags) @@ -1223,7 +1257,8 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { DisableDockerUpdateChecks: *disableDockerUpdateChecksFlag, DisableRegistryCredentials: *disableRegistryCredentialsFlag, DockerRuntime: dockerRuntime, - EnableCommands: resolveEnableCommands(*enableCommandsFlag, *disableCommandsFlag, envEnableCommands, envDisableCommands), + EnableCommands: enableCommands, + CommandAuthorityProfile: commandAuthorityProfile, Enroll: *enrollFlag, HealthAddr: strings.TrimSpace(*healthAddrFlag), KubeconfigPath: strings.TrimSpace(*kubeconfigFlag), diff --git a/cmd/pulse-agent/main_test.go b/cmd/pulse-agent/main_test.go index 28c62c92cd..57278c2727 100644 --- a/cmd/pulse-agent/main_test.go +++ b/cmd/pulse-agent/main_test.go @@ -792,6 +792,64 @@ func TestResolveEnableCommands(t *testing.T) { } } +func TestLoadConfigCommandAuthorityProfiles(t *testing.T) { + for _, tc := range []struct { + name string + args []string + wantProfile hostagent.CommandAuthorityProfile + wantEnabled bool + wantError bool + }{ + {name: "unmarked stays legacy", args: []string{"-token", "test"}, wantProfile: hostagent.CommandAuthorityLegacy}, + {name: "fresh monitoring marker", args: []string{"-token", "test", "-command-authority", "monitoring-only"}, wantProfile: hostagent.CommandAuthorityMonitoringOnly}, + {name: "enable implies capable", args: []string{"-token", "test", "-enable-commands"}, wantProfile: hostagent.CommandAuthorityCommandCapable, wantEnabled: true}, + {name: "explicit capable", args: []string{"-token", "test", "-command-authority", "command-capable"}, wantProfile: hostagent.CommandAuthorityCommandCapable}, + {name: "conflicting authority", args: []string{"-token", "test", "-enable-commands", "-command-authority", "monitoring-only"}, wantError: true}, + {name: "invalid authority", args: []string{"-token", "test", "-command-authority", "root"}, wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg, err := loadConfig(tc.args, func(string) string { return "" }) + if tc.wantError { + if err == nil { + t.Fatalf("loadConfig() accepted invalid authority: %+v", cfg) + } + return + } + if err != nil { + t.Fatalf("loadConfig(): %v", err) + } + if cfg.CommandAuthorityProfile != tc.wantProfile || cfg.EnableCommands != tc.wantEnabled { + t.Fatalf("authority = (%q, enabled=%v), want (%q, enabled=%v)", cfg.CommandAuthorityProfile, cfg.EnableCommands, tc.wantProfile, tc.wantEnabled) + } + }) + } +} + +func TestApplyInitialRemoteCommandAuthority(t *testing.T) { + desired := true + for _, tc := range []struct { + name string + profile hostagent.CommandAuthorityProfile + wantEnable bool + wantAccept bool + }{ + {name: "monitoring rejects startup promotion", profile: hostagent.CommandAuthorityMonitoringOnly, wantAccept: false}, + {name: "command capable accepts startup enable", profile: hostagent.CommandAuthorityCommandCapable, wantEnable: true, wantAccept: true}, + {name: "legacy accepts startup enable", profile: hostagent.CommandAuthorityLegacy, wantEnable: true, wantAccept: true}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := Config{CommandAuthorityProfile: tc.profile} + accepted := applyInitialRemoteCommandAuthority(&cfg, &desired) + if cfg.EnableCommands != tc.wantEnable || accepted != tc.wantAccept { + t.Fatalf("startup authority = (enabled=%v, accepted=%v), want (enabled=%v, accepted=%v)", cfg.EnableCommands, accepted, tc.wantEnable, tc.wantAccept) + } + }) + } + if !applyInitialRemoteCommandAuthority(nil, &desired) { + t.Fatal("nil config should be a no-op") + } +} + func TestResolveToken(t *testing.T) { customStateDir := filepath.Join(string(filepath.Separator), "custom", "pulse-agent") fakeReadFile := func(path string) ([]byte, error) { diff --git a/docs/AGENT_SECURITY.md b/docs/AGENT_SECURITY.md index 5dda2eb377..2b21527516 100644 --- a/docs/AGENT_SECURITY.md +++ b/docs/AGENT_SECURITY.md @@ -28,8 +28,8 @@ root read access: - install it only on hosts you trust Pulse to monitor; - keep the agent token scoped to that Pulse server; -- keep command execution disabled unless you explicitly need governed - remediation; +- keep the local command-authority profile `monitoring-only` unless you + explicitly accept the transitional combined command runtime; - update from signed release assets rather than arbitrary branch snapshots. The agent is primarily an outbound reporter to your Pulse server. By default it @@ -52,12 +52,16 @@ capacity monitoring, for example a log2ram `/var/log` mount. This reports filesystem capacity and usage metadata. It does not read or transmit file contents. Local `--disk-exclude` rules still take precedence. -Command execution is disabled by default. It can be enabled with -`--enable-commands`, `PULSE_ENABLE_COMMANDS=true`, or the centralized agent -command setting after enrollment. Leave it disabled for read-only monitoring. -When enabled, commands still flow through Pulse's command policy and approval -surfaces instead of silently turning every agent into an unrestricted remote -shell. +Fresh installs use a local `monitoring-only` command-authority ceiling and a +credential without `agent:exec`. Remote configuration cannot promote that +service. Selecting the advanced legacy combined command profile at install time +adds `--enable-commands`, records `command-capable`, and issues an execution- +scoped credential. The root monitoring process can then also accept server +command requests through the existing policy and approval surfaces. Existing +unmarked services upgrade as `legacy` during the migration window so upgrades +do not silently revoke an operator's prior command choice. Agent Doctor shows +the process privilege, local authority ceiling, and credential execution scope +separately and warns about mismatches. Custom numeric sensors are a separate, local configuration boundary. Enabling them with `--custom-sensors-file` does not enable remote commands and diff --git a/docs/PRODUCTION_SECURITY.md b/docs/PRODUCTION_SECURITY.md index 939b9fe985..6d2fc393fc 100644 --- a/docs/PRODUCTION_SECURITY.md +++ b/docs/PRODUCTION_SECURITY.md @@ -36,9 +36,10 @@ temperature sensors, Docker or Podman sockets, host-local storage state, and some platform integrations require root or equivalent access. That is a real security boundary, not a cosmetic implementation detail. -The default posture limits that boundary: +The fresh-install posture limits that boundary: -- command execution is disabled unless an operator explicitly enables it; +- the service is marked `monitoring-only`, remote configuration cannot promote + it, and its credential omits `agent:exec`; - the health and Prometheus listener binds to `127.0.0.1:9191` by default; - generated systemd units apply service hardening including `NoNewPrivileges=true`, private temporary storage, and kernel/control-group @@ -48,6 +49,13 @@ The default posture limits that boundary: - Proxmox guest Docker inventory through `pct exec` is disabled by default and requires an explicit server setting. +The advanced **legacy combined command profile** is a separate trust decision. +It marks the root collector command-capable and gives its credential execution +scope so the same process can accept governed server requests. Existing +unmarked installations remain in a visible `legacy` compatibility state during +the migration. Agent Doctor reports both local authority and credential scope +so an over-scoped monitoring install is not mistaken for a safe default. + On standard Linux systemd hosts the installer also offers a supported least-privilege profile: `--least-privilege` runs the service as a dedicated `pulse-agent` system user, with optional `--grant-smart` and `--grant-pct` diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 351c8913c4..3cbd2ff6e5 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -34,6 +34,17 @@ management, and fleet control surfaces. Pulse v6 has one host-installed infrastructure agent binary, `pulse-agent`; host, Docker / Podman, Kubernetes, Proxmox-local, and other node-local telemetry are modules inside that binary, not separate customer-facing agent products. +Fresh installs carry an explicit local command-authority profile. The closed +values are `monitoring-only`, `command-capable`, and `legacy`. A +`monitoring-only` service may accept remote configuration that keeps commands +off, but remote configuration must never promote it to command-capable or +advance the accepted configuration fingerprint for that rejected promotion. +An explicit command-enabled install is `command-capable`; an already-installed +service with no marker remains `legacy` during the compatibility window so an +upgrade does not silently revoke an existing operator choice. Installers must +persist the marker in the service command line and recover it during updates. +The reported privilege projection carries this local ceiling independently of +the current `commandsEnabled` state and the server-issued credential scope. The host telemetry module owns lossless physical-disk report assembly. Linux smartctl permission/open failures are not standby; only the explicit `-n standby,3` exit contract may mark a device sleeping. Identity-only rows diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 0d4c31f6f2..fd098c79e8 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -159,6 +159,14 @@ Proxmox installer generation. When true, the returned command uses insecure TLS for the installer download and passes `--insecure` to the agent; omission or false preserves certificate verification. The server, not the browser, continues to mint and quote the enrollment token. +Install-command credentials are monitoring-only unless the request explicitly +selects command execution. Monitoring credentials carry report and config-read +scope without `agent:exec`; explicit command-capable credentials add +`agent:exec`. Hosted PVE, PBS, and PMG commands do not infer execution authority +from the target platform. Agent Doctor exposes the local command-authority +profile and the server-derived credential execution scope as separate fields, +and reports an over-scoped monitoring credential rather than hiding it behind +the current command-enabled boolean. ## Canonical Files diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index b9b6d9ae9b..48123b40fa 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -100,6 +100,16 @@ upgrade must recover those values from both split and `--key=value` service arguments and reproduce them in the generated systemd command without discarding existing explicit disk exclusions. +Unified Agent installers persist `--command-authority` as an explicit local +service invariant. Fresh installs default to `monitoring-only`; an explicit +command-enabled install uses `command-capable`; an upgrade of an older service +with no marker writes `legacy` to preserve compatibility. A contradictory +monitoring-only plus command-enabled request fails closed. Linux systemd units +must grant ambient `CAP_SETUID`/`CAP_SETGID` only to the explicit command-capable +PVE profile; fresh monitoring PVE, PBS/PMG, and least-privilege units retain no +such ambient capabilities. Windows service replacement recovers the same +authority marker from the existing service command line before reinstalling it. + Published exact-version install and rollback guidance must preserve the server and Unified Agent installer boundary. Supported systemd and Proxmox LXC deployments use the signed `/bin/update --version vX.Y.Z` server helper; diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index ce317b659a..72ba8968a7 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -30,6 +30,14 @@ product. Only PVE host profiles require a link to a PVE node; an explicit PBS profile, or an auto/missing-type host that matches a configured PBS instance by normalized hostname or reported address, must not receive the PVE-only `proxmox_profile_unlinked` warning. +Agent Doctor also projects privilege and command authority without conflating +them. The process user, local `monitoring-only`/`command-capable`/`legacy` +ceiling, and server-derived credential execution scope are distinct evidence. +A monitoring-only runtime with `agent:exec` is an over-scope warning; a +command-capable runtime whose monitoring credential cannot re-enable commands +is a scope-mismatch warning; an actively command-enabled runtime without +`agent:exec` remains critical. These facts do not change collection truth or +make a least-privilege service unhealthy by themselves. Monitor construction also applies the persisted alert schedule's normalized initial-delivery target to the tenant notification manager. This is runtime wiring only: monitoring does not choose destinations or own notification diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index b4873478aa..eeddaaebdf 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -28,6 +28,14 @@ An older watcher snapshot may never overwrite newer runtime and durable token state; `TestConfigWatcher_ReloadAPITokensDoesNotOverwriteConcurrentMutation` is the required concurrency proof. +Pulse-minted Unified Agent credentials follow least authority at issuance. +Implicit and ordinary monitoring installs receive `agent:report` and +`agent:config:read`, never `agent:exec`. Execution scope is added only for an +explicit command-capable installer choice. Local runtime authority remains an +independent ceiling: a credential with `agent:exec` cannot promote a service +installed as `monitoring-only`, and Agent Doctor must warn when such excess +scope remains attached to that runtime. + Own Pulse's canonical privacy disclosures, outbound usage-data boundary, and the security-facing settings surfaces that expose authentication posture, token-management visibility, and privacy controls to operators. Customer-facing diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 93b5c82034..1b73f5b7af 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -133,6 +133,10 @@ Agent-install command generation under `internal/api/` may carry an explicit TLS-verification override, but it does not alter storage credentials, backup authority, restore behavior, or persisted recovery state. The generated command remains a bounded onboarding artifact with server-owned token quoting. +Its monitoring-default credential and local command-authority marker likewise +grant no backup mutation, restore, or recovery-state authority; `agent:exec` +appears only when the operator explicitly requests the transitional +command-capable profile. ## Canonical Files diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index f596f82530..884e8c841a 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,38 +1,26 @@ { "version": 1, - "base_sha": "5926b4dc38c84e086593476cec1d65c047ef1182", - "verified_at": "2026-08-29T20:31:20Z", + "base_sha": "6453593b4b984eaa7d1d40727f43e12528212dd7", + "verified_at": "2026-08-29T21:11:53Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/AppLayout.tsx", - "frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx", - "frontend-modern/src/components/shared/MobileNavBar.tsx", - "frontend-modern/src/components/shared/mobileNavBarModel.ts", - "frontend-modern/src/components/shared/useMobileNavBarState.ts", - "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx", - "frontend-modern/src/hooks/useUnifiedResources.ts", - "frontend-modern/src/types/api.ts", - "frontend-modern/src/types/resource.ts", - "frontend-modern/src/utils/proxmoxUpdateEvidence.ts", - "frontend-modern/src/utils/resourceStateAdapters.ts" + "frontend-modern/src/api/agentDiagnostics.ts", + "frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx", + "frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx", + "frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts", + "frontend-modern/src/components/Settings/useNodeModalState.ts" ], "content_sha256": { - "frontend-modern/src/AppLayout.tsx": "db685903fef2a1509edd812acb3904f7ff3ffab0bbd1af2bff183c1b3ae8e373", - "frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx": "15aa9c5dedebb2024f2548854996034672b35412708f28c276bd108892056e64", - "frontend-modern/src/components/shared/MobileNavBar.tsx": "27358f3e77267cc111ce97b32d715b0069e2d1370ddd2b3ecc4f2d34a3e6dac5", - "frontend-modern/src/components/shared/mobileNavBarModel.ts": "ab9e69d379579d02e33a2abd1224d8c13e6543dd6aa0ad40cc1f65697a289d04", - "frontend-modern/src/components/shared/useMobileNavBarState.ts": "74b5b354d302d5e5ea136131f06b3a8c1772a83a9903351737b9931f8136cc16", - "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "bbc7047317b350074e2f234b2472f3a1b39b080e4ea4fddff706a866952f473f", - "frontend-modern/src/hooks/useUnifiedResources.ts": "cbf1b5a2fa2bdb51d582f699fef97203bac2d2ab559f29ea40821e2eb7c11ee3", - "frontend-modern/src/types/api.ts": "d3dfc213bc7b7c357954723a22a21744e6be5da9f34d97a1faf00f579efcb5fc", - "frontend-modern/src/types/resource.ts": "126473e475c63c995aa86c578e9bc8f115eaca98da6477545e4389fe96b43f68", - "frontend-modern/src/utils/proxmoxUpdateEvidence.ts": "3881918d823b440cf8739e14da780c7e98e871e71b9be490ca61a7fbc342c29e", - "frontend-modern/src/utils/resourceStateAdapters.ts": "6f077a5611c8563be3674d9b8baf2500c711494bae14fda697f56b1e00c2342b" + "frontend-modern/src/api/agentDiagnostics.ts": "0bf0f3d0854ebd03ab9d80ccef0e39655aeeb2bd16d18901b527ba3955a086bb", + "frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx": "0fe6f4cb3f10adbf13306eaa73b390e24b4d586ec71479cb7c8494ce158e6302", + "frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx": "5aa559093039bc18785b4df88c2c955d67465b969b85979bc2a836a7d64d6dd0", + "frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts": "d352500b9f804111fe07602aa3716e9d1ff1a30ac5fe3b1d6a7d0587c36c6171", + "frontend-modern/src/components/Settings/useNodeModalState.ts": "0f198074f88ef8d55a0fdc5142bacee4a39b572b12f94f7ed88a0fa10724c067" }, "routes": [ - "/proxmox/overview?status=running", - "/alerts", - "/actions" + "/settings/infrastructure?add=docker", + "/settings/infrastructure?add=pve", + "/settings/infrastructure/agent-doctor" ], "viewports": [ { @@ -45,13 +33,15 @@ } ], "states": [ - "mock-backed Proxmox overview with multiple platform destinations and a remembered status query", - "mobile fixed navigation rail with the platform menu open", - "current NodeDrawerOverview mounted with synthetic checked-zero, stale-positive, and permission-unavailable update evidence fixtures" + "mock-backed Docker installer before token generation, including the transitional Docker-in-LXC trust notice", + "Proxmox VE onboarding with the API-first strategy selected", + "Proxmox VE onboarding with Host Telemetry Agent selected and monitoring-only credential guidance visible", + "Agent Doctor route with the current fleet diagnostics loaded" ], "interactions": [ - "inspected destination hrefs, opened the platform menu, closed it with Escape and verified focus returned to its trigger", - "used a modified Alerts click to open a native browser tab while retaining the original Proxmox route, then used a plain Actions click for in-app navigation", - "mounted the current NodeDrawerOverview component in the Vite browser, checked all three evidence states at desktop width, then narrowed to 390 pixels and verified no horizontal overflow" + "opened the Docker onboarding deep link and verified monitoring-first copy, token authorization guidance, and install handoff at desktop width", + "narrowed the Docker onboarding dialog to 390 pixels and verified the title, Docker-in-LXC notice, and token action remained visible", + "opened Proxmox VE onboarding, switched from Connect via API to Host Telemetry Agent, and verified the monitoring-only command-authority boundary copy", + "opened Agent Doctor and verified the routed diagnostics surface loaded without console-blocking UI failures" ] } diff --git a/frontend-modern/src/api/agentDiagnostics.ts b/frontend-modern/src/api/agentDiagnostics.ts index 5d31d9e7e5..d13c5781c9 100644 --- a/frontend-modern/src/api/agentDiagnostics.ts +++ b/frontend-modern/src/api/agentDiagnostics.ts @@ -51,6 +51,9 @@ export interface AgentFleetDiagnosticModule { export interface AgentFleetDiagnosticPrivilege { runningAsRoot: boolean; serviceUser?: string; + commandAuthority?: 'monitoring-only' | 'command-capable' | 'legacy' | string; + credentialKnown?: boolean; + credentialExec?: boolean; smartctlHelper?: boolean; pctHelper?: boolean; } diff --git a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx index 8c857e02fd..4d5ac98163 100644 --- a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx @@ -82,7 +82,7 @@ const INSTALLER_FOCUS_PRESENTATION: Record< 'Run on the machine that runs Docker or Podman. For Docker inside Proxmox LXCs, use the Proxmox node path below instead of installing inside every guest.', recommendationTitle: 'Docker install path', recommendationDetail: - 'For a standalone Docker or Podman host, install Pulse Agent on that host. The Docker profile is selected for this flow so copied commands force runtime monitoring when automatic detection is restricted. For Docker inside Proxmox LXCs, install on the Proxmox node, select the Proxmox VE node profile, and enable command execution so the server-opted-in LXC inventory path can run.', + 'For a standalone Docker or Podman host, install Pulse Agent on that host. The Docker profile is selected for this flow so copied commands force runtime monitoring when automatic detection is restricted. Docker discovery inside Proxmox LXCs still uses the transitional command-capable profile until the scoped local helper is available.', preferredProfile: 'docker', platforms: ['linux'], }, @@ -118,7 +118,7 @@ const getCommandSectionDescription = ( return 'Use the Linux installer from Unraid terminal or SSH. The agent stores its local uninstall helper under the Unraid plugin path.'; } if (focus === 'docker') { - return 'Use the Linux installer on a standalone Docker or Podman host. For Docker inside Proxmox LXCs, switch Target profile to Proxmox VE node and enable command execution.'; + return 'Use the Linux installer on a standalone Docker or Podman host. Docker discovery inside Proxmox LXCs currently requires the advanced, command-capable Proxmox install profile.'; } if (focus === 'kubernetes') { return 'Use the Linux installer on a Kubernetes node. Commands in this flow include the Kubernetes profile.'; @@ -289,9 +289,10 @@ export const InfrastructureInstallerSection: Component Install the agent on the Proxmox node, not inside every LXC. In advanced options, select Proxmox VE node and enable{' '} - Pulse command execution. Then turn on{' '} - Discover Docker in LXC guests in Settings → System → - General (admin only), or set{' '} + the legacy combined command profile. This is a + temporary full command-capable runtime, not a least-privilege monitoring install. Then + turn on Discover Docker in LXC guests in Settings → + System → General (admin only), or set{' '} PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY=true in the server environment to lock it on. Optionally restrict it with{' '} PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS=101,102. Pulse uses bounded{' '} @@ -604,7 +605,7 @@ export const InfrastructureInstallerSection: Component - Enable Pulse command execution (Patrol actions and Proxmox LXC Docker inventory) + Enable legacy combined command profile
- Pulse commands enabled: The agent will accept - Pulse-scoped command requests. On Proxmox nodes, this is required for opted-in - Docker-in-LXC inventory because Pulse runs bounded pct exec{' '} - Docker summary checks. + Legacy combined trust enabled: The root + monitoring service will also accept server command requests. On Proxmox nodes, + this remains necessary for opted-in Docker-in-LXC inventory until the scoped + local helper replaces the current pct exec path.
diff --git a/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx b/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx index 801067e541..1bdf60fd18 100644 --- a/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx +++ b/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx @@ -20,7 +20,7 @@ const getNodeSetupStrategyPresentation = ( if (setupMode === 'agent') { return { label: 'Host telemetry agent', - detail: `Optional full host telemetry: creates the ${productLabel} API token, installs Pulse Agent as the supported root service, and registers the source automatically.`, + detail: `Optional full host telemetry: creates a monitoring-only ${productLabel} credential, installs the current Pulse Agent root service, and registers the source automatically.`, }; } @@ -138,17 +138,19 @@ export const NodeModalSetupGuideSection: Component
  • - Enables Pulse command execution for Patrol actions and opted-in Docker-in-LXC - inventory + Uses a monitoring-only Pulse credential. This setup does not grant remote + command authority
  • - Docker inside Proxmox LXCs: use this host-agent path instead - of installing Pulse Agent in every guest. The copied command enables Pulse - command execution on the Proxmox node. The Pulse server still must be opted - in: turn on Discover Docker in LXC guests in - Settings → System → General (admin only), or set{' '} + Docker inside Proxmox LXCs: this monitoring-only setup does + not grant the command authority currently required for guest Docker discovery. + Until the typed local helper replaces that path, use the advanced Pulse Agent + installer only if you explicitly accept the combined command profile. The + Pulse server must also be opted in: turn on{' '} + Discover Docker in LXC guests in Settings → + System → General (admin only), or set{' '} PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY=true {' '} @@ -644,7 +646,7 @@ export const NodeModalSetupGuideSection: Component Host-local telemetry such as temperatures, SMART, services, and local disks -

  • Agent-driven commands and service telemetry when explicitly used
  • +
  • Monitoring-only Pulse credential with no remote command authority
  • Automatic reconnection on network issues
  • diff --git a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts index 7a91fccdaa..da6538477f 100644 --- a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts @@ -161,6 +161,28 @@ describe('Agent Doctor model', () => { expect(report).toContain('Privilege least privilege (pulse-agent) · SMART helper active'); }); + it('shows local command authority separately from credential execution authority', () => { + const connection = connectionFixture(); + const [target] = collectInfrastructureAgentDoctorTargets({ + rows: [rowFixture(connection)], + connections: [connection], + diagnostics: [ + diagnosticFixture({ + privilege: { + runningAsRoot: true, + commandAuthority: 'monitoring-only', + credentialKnown: true, + credentialExec: true, + }, + }), + ], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }); + + expect(target.privilegeLabel).toBe('root · commands monitoring-only · credential grants exec'); + }); + it('turns a missing credential into a token-gated authentication repair', () => { const connection = connectionFixture({ agentUpdateAvailable: false, diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index fd3b005de0..977985cce8 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1730,7 +1730,7 @@ describe('settings architecture guardrails', () => { expect(nodeModalStateSource).not.toContain('quickSetupPreviewCommand'); expect(nodeModalStatusFooterSource).toContain('guidedSetupOnlyMode'); expect(nodeModalStatusFooterSource).toContain('props.saveDisabled'); - expect(nodeModalStateSource).toContain("enableCommands: type === 'pve'"); + expect(nodeModalStateSource).toContain('enableCommands: false'); expect(nodeModalStateSource).toContain('data.setupMode !=='); // The typed connection name rides the setup bootstrap request so the // one-time setup token can carry it into auto-registration naming. diff --git a/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts b/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts index 7843f69394..6ec04365d4 100644 --- a/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts +++ b/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts @@ -673,10 +673,18 @@ const diagnosticProfileVersionLabel = ( const diagnosticPrivilegeLabel = (diagnostic?: AgentFleetAgentDiagnostic): string | undefined => { const privilege = diagnostic?.privilege; if (!privilege) return undefined; - if (privilege.runningAsRoot) return 'root'; const user = privilege.serviceUser?.trim(); - const base = user ? `least privilege (${user})` : 'least privilege'; + const process = privilege.runningAsRoot + ? 'root' + : user + ? `least privilege (${user})` + : 'least privilege'; + const authority = privilege.commandAuthority?.trim(); + const base = authority ? `${process} · commands ${authority}` : process; const helpers: string[] = []; + if (privilege.credentialKnown) { + helpers.push(privilege.credentialExec ? 'credential grants exec' : 'monitoring credential'); + } if (privilege.smartctlHelper) helpers.push('SMART helper active'); if (privilege.pctHelper) helpers.push('pct helper active'); return helpers.length > 0 ? `${base} · ${helpers.join(' · ')}` : base; diff --git a/frontend-modern/src/components/Settings/useNodeModalState.ts b/frontend-modern/src/components/Settings/useNodeModalState.ts index f8970f047e..e8b6249adf 100644 --- a/frontend-modern/src/components/Settings/useNodeModalState.ts +++ b/frontend-modern/src/components/Settings/useNodeModalState.ts @@ -84,7 +84,7 @@ export const useNodeModalState = (props: NodeModalProps) => { const data = await NodesAPI.getAgentInstallCommand({ type, enableProxmox: true, - enableCommands: type === 'pve', + enableCommands: false, insecure: agentInstallInsecure(), }); setAgentInstallCommand(data.command); diff --git a/internal/api/agent_install_command_shared.go b/internal/api/agent_install_command_shared.go index 2bcc54b378..9f7d68ced9 100644 --- a/internal/api/agent_install_command_shared.go +++ b/internal/api/agent_install_command_shared.go @@ -34,8 +34,8 @@ func normalizeProxmoxInstallType(raw string) (string, error) { return installType, nil } -func proxmoxAgentInstallScopes() []string { - return agenttokens.ProxmoxScopes() +func proxmoxAgentInstallScopes(enableCommands bool) []string { + return agenttokens.ProxmoxScopes(enableCommands) } // hostAgentInstallScopes returns the scopes for a generic unified host agent diff --git a/internal/api/agent_install_command_shared_test.go b/internal/api/agent_install_command_shared_test.go index 5f480419d8..be34766235 100644 --- a/internal/api/agent_install_command_shared_test.go +++ b/internal/api/agent_install_command_shared_test.go @@ -48,11 +48,39 @@ func TestAgentInstallCommandCarriesCommandPolicyIntent(t *testing.T) { if got := cfg.APITokens[0].Metadata[agenttokens.CommandPolicyIntentMetadataKey]; got != agenttokens.CommandPolicyIntentEnabled { t.Fatalf("command policy intent = %q, want enabled", got) } + if !cfg.APITokens[0].HasScope(config.ScopeAgentExec) { + t.Fatalf("explicit command install token is missing %s: %v", config.ScopeAgentExec, cfg.APITokens[0].Scopes) + } if !strings.Contains(response.Command, "--enable-commands") { t.Fatalf("commands-enabled install omitted flag: %s", response.Command) } } +func TestAgentInstallCommandDefaultsToMonitoringOnlyAuthority(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir(), AuthUser: "admin", AuthPass: "hashed-password"} + handler := newTestConfigHandlers(t, cfg) + req := httptest.NewRequest( + http.MethodPost, + "/api/agent-install-command", + bytes.NewBufferString(`{"type":"pve"}`), + ) + req.Host = "pulse.example:7655" + rec := httptest.NewRecorder() + handler.HandleAgentInstallCommand(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("install command status = %d: %s", rec.Code, rec.Body.String()) + } + if len(cfg.APITokens) != 1 { + t.Fatalf("install command minted %d tokens, want 1", len(cfg.APITokens)) + } + if cfg.APITokens[0].HasScope(config.ScopeAgentExec) { + t.Fatalf("monitoring-only install token includes %s: %v", config.ScopeAgentExec, cfg.APITokens[0].Scopes) + } + if got := cfg.APITokens[0].Metadata[agenttokens.CommandPolicyIntentMetadataKey]; got != agenttokens.CommandPolicyIntentDisabled { + t.Fatalf("command policy intent = %q, want disabled", got) + } +} + func TestBuildProxmoxAgentInstallCommand(t *testing.T) { command := buildProxmoxAgentInstallCommand(agentInstallCommandOptions{ BaseURL: "https://pulse.example.com/", diff --git a/internal/api/agenttokens/install.go b/internal/api/agenttokens/install.go index f2be1118a0..d18f0effd6 100644 --- a/internal/api/agenttokens/install.go +++ b/internal/api/agenttokens/install.go @@ -34,13 +34,16 @@ type IssueOptions struct { Scopes []string } -func ProxmoxScopes() []string { - return []string{ +func ProxmoxScopes(enableCommands bool) []string { + scopes := []string{ config.ScopeAgentReport, config.ScopeAgentConfigRead, config.ScopeAgentManage, - config.ScopeAgentExec, } + if enableCommands { + scopes = append(scopes, config.ScopeAgentExec) + } + return scopes } func HostScopes(enableCommands bool) []string { @@ -90,7 +93,10 @@ func IssueAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, scopes := opts.Scopes if len(scopes) == 0 { - scopes = ProxmoxScopes() + // A caller that does not declare an authority profile receives a + // monitoring-only credential. Command authority must always be an + // explicit install-time choice. + scopes = ProxmoxScopes(false) } record, err := config.NewAPITokenRecord(rawToken, opts.TokenName, scopes) if err != nil { diff --git a/internal/api/agenttokens/install_test.go b/internal/api/agenttokens/install_test.go index f5f3e26434..6cbfb6eaee 100644 --- a/internal/api/agenttokens/install_test.go +++ b/internal/api/agenttokens/install_test.go @@ -32,6 +32,29 @@ func TestIssueAndPersistInstallToken(t *testing.T) { } } +func TestProxmoxScopesRequireExplicitCommandAuthority(t *testing.T) { + monitoringScopes := ProxmoxScopes(false) + if (&config.APITokenRecord{Scopes: monitoringScopes}).HasScope(config.ScopeAgentExec) { + t.Fatalf("monitoring-only Proxmox scopes include %s: %v", config.ScopeAgentExec, monitoringScopes) + } + + commandScopes := ProxmoxScopes(true) + if !(&config.APITokenRecord{Scopes: commandScopes}).HasScope(config.ScopeAgentExec) { + t.Fatalf("explicit command profile is missing %s: %v", config.ScopeAgentExec, commandScopes) + } +} + +func TestIssueAndPersistDefaultsToMonitoringOnlyScopes(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + _, record, err := IssueAndPersist(cfg, nil, IssueOptions{TokenName: "default-agent"}) + if err != nil { + t.Fatalf("IssueAndPersist: %v", err) + } + if record.HasScope(config.ScopeAgentExec) { + t.Fatalf("implicit install scopes include %s: %v", config.ScopeAgentExec, record.Scopes) + } +} + func TestIssueAndPersistRollsBackCompleteInventoryWhenPersistenceFails(t *testing.T) { now := time.Now().UTC() tokens := []config.APITokenRecord{ diff --git a/internal/api/cloud_agent_install_command.go b/internal/api/cloud_agent_install_command.go index 08cb33edfb..5e3bfc381e 100644 --- a/internal/api/cloud_agent_install_command.go +++ b/internal/api/cloud_agent_install_command.go @@ -72,14 +72,14 @@ func GenerateHostedTenantAgentInstallCommand(opts HostedTenantAgentInstallComman TokenName: tokenName, OrgID: orgID, OwnerUserID: strings.TrimSpace(opts.OwnerUserID), + Scopes: proxmoxAgentInstallScopes(false), Metadata: map[string]string{ "install_type": installType, "issued_via": "hosted_agent_install_command", - // Hosted installs have no enableCommands toggle; commands follow the - // install type like the self-hosted flow (pve on, pbs off). Without - // this stamp, reconcileInstallTokenCommandPolicy skips the token and - // a reinstall never clears a stale disabled policy (#1728). - agenttokens.CommandPolicyIntentMetadataKey: agenttokens.CommandPolicyIntent(installType == proxmoxInstallTypePVE), + // Hosted installs have no command-authority enrollment step. Keep the + // credential monitoring-only and stamp the disabled intent so a + // reinstall clears any stale combined-runtime command policy (#1728). + agenttokens.CommandPolicyIntentMetadataKey: agenttokens.CommandPolicyIntent(false), }, }) if err != nil { diff --git a/internal/api/configapi/config_setup_handlers.go b/internal/api/configapi/config_setup_handlers.go index fa4d4c3703..8eae0f5914 100644 --- a/internal/api/configapi/config_setup_handlers.go +++ b/internal/api/configapi/config_setup_handlers.go @@ -2570,6 +2570,7 @@ func (h *ConfigHandlers) handleAgentInstallCommand(w http.ResponseWriter, r *htt rawToken, _, err = issueAndPersistAgentInstallToken(cfg, persistence, issueAgentInstallTokenOptions{ TokenName: tokenName, OwnerUserID: h.apiTokenOwnerUserIDForRequest(cfg, r), + Scopes: proxmoxAgentInstallScopes(req.EnableCommands), Metadata: map[string]string{ "install_type": installType, "issued_via": "config_agent_install_command", diff --git a/internal/api/configapi/dependencies.go b/internal/api/configapi/dependencies.go index c5532123e8..06561b33f4 100644 --- a/internal/api/configapi/dependencies.go +++ b/internal/api/configapi/dependencies.go @@ -121,6 +121,10 @@ func hostAgentInstallScopes(enableCommands bool) []string { return agenttokens.HostScopes(enableCommands) } +func proxmoxAgentInstallScopes(enableCommands bool) []string { + return agenttokens.ProxmoxScopes(enableCommands) +} + func agentInstallCommandPolicyIntent(enableCommands bool) string { return agenttokens.CommandPolicyIntent(enableCommands) } diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 397228648d..8026d5960b 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -37,6 +37,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/ai/unified" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens" "github.com/rcourtman/pulse-go-rewrite/internal/api/chartapi" "github.com/rcourtman/pulse-go-rewrite/internal/api/configapi" "github.com/rcourtman/pulse-go-rewrite/internal/api/resourceapi" @@ -69,6 +70,29 @@ import ( tmock "github.com/stretchr/testify/mock" ) +func TestContractProxmoxInstallScopesRequireExplicitCommandChoice(t *testing.T) { + monitoringScopes := proxmoxAgentInstallScopes(false) + monitoringRecord := &config.APITokenRecord{Scopes: monitoringScopes} + if monitoringRecord.HasScope(config.ScopeAgentExec) { + t.Fatalf("monitoring Proxmox install scopes = %v, must not grant agent:exec", monitoringScopes) + } + + commandScopes := proxmoxAgentInstallScopes(true) + commandScopeRecord := &config.APITokenRecord{Scopes: commandScopes} + if !commandScopeRecord.HasScope(config.ScopeAgentExec) { + t.Fatalf("command-capable Proxmox install scopes = %v, want agent:exec", commandScopes) + } + commandRecord := &config.APITokenRecord{ + Scopes: commandScopes, + Metadata: map[string]string{ + agenttokens.CommandPolicyIntentMetadataKey: agenttokens.CommandPolicyIntent(true), + }, + } + if enabled, ok := agenttokens.ParseCommandPolicyIntent(commandRecord); !ok || !enabled { + t.Fatal("explicit command-capable install lost its command-policy intent") + } +} + func TestContractPatrolInternalBridgePreservesBoundedToolAuthority(t *testing.T) { allowed := []string{ agentcapabilities.PatrolGetFindingsToolName, diff --git a/internal/api/hosted_agent_install_command_test.go b/internal/api/hosted_agent_install_command_test.go index 3ef53e758f..6b47686bda 100644 --- a/internal/api/hosted_agent_install_command_test.go +++ b/internal/api/hosted_agent_install_command_test.go @@ -177,7 +177,7 @@ func TestHostedTenantAgentInstallCommandCarriesCommandPolicyIntent(t *testing.T) installType string wantIntent string }{ - {installType: "pve", wantIntent: agenttokens.CommandPolicyIntentEnabled}, + {installType: "pve", wantIntent: agenttokens.CommandPolicyIntentDisabled}, {installType: "pbs", wantIntent: agenttokens.CommandPolicyIntentDisabled}, } { result, err := GenerateHostedTenantAgentInstallCommand(HostedTenantAgentInstallCommandOptions{ @@ -200,6 +200,8 @@ func TestHostedTenantAgentInstallCommandCarriesCommandPolicyIntent(t *testing.T) } require.NotNil(t, record, "minted token record not found for %s", tc.installType) require.Equal(t, tc.wantIntent, record.Metadata[agenttokens.CommandPolicyIntentMetadataKey], "install type %s", tc.installType) + require.False(t, record.HasScope(config.ScopeAgentExec), "hosted monitoring token must not include exec for %s", tc.installType) + require.NotContains(t, result.Command, "--enable-commands", "hosted monitoring command must not enable exec for %s", tc.installType) } } diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index ba5981d603..b1e5e01632 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -32,6 +32,21 @@ type wsRawMessage struct { Payload json.RawMessage `json:"payload,omitempty"` } +func TestSecurityImplicitAgentCredentialOmitsExecutionScope(t *testing.T) { + _, record, err := agenttokens.IssueAndPersist(&config.Config{}, nil, agenttokens.IssueOptions{ + TokenName: "implicit-monitoring-agent", + }) + if err != nil { + t.Fatalf("IssueAndPersist: %v", err) + } + if !record.HasScope(config.ScopeAgentReport) || !record.HasScope(config.ScopeAgentConfigRead) { + t.Fatalf("implicit credential scopes = %v, want monitoring scopes", record.Scopes) + } + if record.HasScope(config.ScopeAgentExec) { + t.Fatalf("implicit credential scopes = %v, must not grant agent:exec", record.Scopes) + } +} + func TestSecurityGenericExecTokenCannotApplyInstallCommandPolicy(t *testing.T) { token := config.APITokenRecord{ ID: "generic-exec-token", diff --git a/internal/hostagent/agent.go b/internal/hostagent/agent.go index 0d5f7f388d..9853f8ffb4 100644 --- a/internal/hostagent/agent.go +++ b/internal/hostagent/agent.go @@ -62,7 +62,8 @@ type Config struct { ProxmoxType string // "pve", "pbs", or "" for auto-detect // Security options - EnableCommands bool // If true, enables the command execution feature (AI auto-fix) + EnableCommands bool // If true, enables the command execution feature (AI auto-fix) + CommandAuthorityProfile CommandAuthorityProfile // Immutable local ceiling; empty preserves legacy update continuity. // Enrollment Enroll bool // If true, exchange bootstrap token for runtime token on startup @@ -172,15 +173,20 @@ type Agent struct { commandClientMu sync.Mutex commandClientRunCancel context.CancelFunc commandClientParentCtx context.Context - collector SystemCollector - newCommandClient func(Config, string, string, string, string) *CommandClient - runCommandClient func(*CommandClient, context.Context) error - packageUpdates *packageUpdateManager - storageCleanup *storageCleanupManager - customSensors *customSensorRuntime - availability *availabilityProbeModule - reportStreamID string - reportSequence atomic.Uint64 + // commandAuthorityProfile is the immutable local install-time ceiling for + // this process. Remote configuration may disable and later re-enable a + // command-capable or legacy install, but it cannot promote monitoring-only. + commandAuthorityProfile CommandAuthorityProfile + commandAuthorityWarningSeen bool + collector SystemCollector + newCommandClient func(Config, string, string, string, string) *CommandClient + runCommandClient func(*CommandClient, context.Context) error + packageUpdates *packageUpdateManager + storageCleanup *storageCleanupManager + customSensors *customSensorRuntime + availability *availabilityProbeModule + reportStreamID string + reportSequence atomic.Uint64 // canonicalIDWarned holds the last server-acknowledged agent ID a // mismatch warning was emitted for, so the warning fires once per // resolved identity instead of on every report cycle. Only the @@ -216,6 +222,15 @@ func (e *reportHTTPStatusError) Error() string { // New constructs a fully initialised runtime-side Unified Agent. func New(cfg Config) (*Agent, error) { + commandAuthorityProfile, err := NormalizeCommandAuthorityProfile(string(cfg.CommandAuthorityProfile)) + if err != nil { + return nil, err + } + cfg.CommandAuthorityProfile = commandAuthorityProfile + if effectiveCommands, accepted := ResolveCommandAuthority(commandAuthorityProfile, cfg.EnableCommands); !accepted { + cfg.EnableCommands = effectiveCommands + } + if cfg.Interval <= 0 { cfg.Interval = defaultInterval } @@ -247,7 +262,6 @@ func New(cfg Config) (*Agent, error) { if pulseURL == "" { pulseURL = "http://localhost:7655" } - var err error pulseURL, err = normalizePulseURL(pulseURL) if err != nil { return nil, fmt.Errorf("invalid pulse URL: %w", err) @@ -395,36 +409,37 @@ func New(cfg Config) (*Agent, error) { cfg.storageCleanup = storageCleanup agent := &Agent{ - cfg: cfg, - logger: logger, - httpClient: client, - hostInfo: info, - hostname: hostname, - displayName: displayName, - platform: platform, - osName: osName, - osVersion: osVersion, - kernelVersion: kernelVersion, - architecture: arch, - machineID: machineID, - agentID: agentID, - agentVersion: agentVersion, - updatedFrom: updatedFrom, - reportIP: cfg.ReportIP, - stateDir: cfg.StateDir, - interval: cfg.Interval, - trimmedPulseURL: pulseURL, - remoteConfigChanged: make(chan struct{}, 1), - reportBuffer: utils.New[agentshost.Report](bufferCapacity), - observerReporters: observerReporters, - collector: collector, - newCommandClient: newCommandClientFn, - runCommandClient: runCommandClientFn, - packageUpdates: packageUpdates, - storageCleanup: storageCleanup, - customSensors: newCustomSensorRuntime(customSensorDefinitions, cfg.customSensorExecute), - availability: newAvailabilityProbeModule(logger, cfg.AvailabilityTargets), - reportStreamID: reportStreamID, + cfg: cfg, + logger: logger, + httpClient: client, + hostInfo: info, + hostname: hostname, + displayName: displayName, + platform: platform, + osName: osName, + osVersion: osVersion, + kernelVersion: kernelVersion, + architecture: arch, + machineID: machineID, + agentID: agentID, + agentVersion: agentVersion, + updatedFrom: updatedFrom, + reportIP: cfg.ReportIP, + stateDir: cfg.StateDir, + interval: cfg.Interval, + trimmedPulseURL: pulseURL, + remoteConfigChanged: make(chan struct{}, 1), + reportBuffer: utils.New[agentshost.Report](bufferCapacity), + observerReporters: observerReporters, + collector: collector, + newCommandClient: newCommandClientFn, + runCommandClient: runCommandClientFn, + commandAuthorityProfile: commandAuthorityProfile, + packageUpdates: packageUpdates, + storageCleanup: storageCleanup, + customSensors: newCustomSensorRuntime(customSensorDefinitions, cfg.customSensorExecute), + availability: newAvailabilityProbeModule(logger, cfg.AvailabilityTargets), + reportStreamID: reportStreamID, } if len(customSensorDefinitions) > 0 { logger.Info(). @@ -1214,7 +1229,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) { AppliedConfig: runtimeConfig.appliedConfig, Update: a.currentUpdateStatus(), Modules: moduleStatus, - Privilege: collectPrivilegeStatus(), + Privilege: collectPrivilegeStatus(a.commandAuthorityProfile), }, Host: agentshost.HostInfo{ ID: a.machineID, @@ -1436,8 +1451,9 @@ func (a *Agent) persistAgentID(agentID string) { // ApplyRemoteConfig applies server-side configuration overrides that can be // reconciled safely without restarting the agent process. func (a *Agent) ApplyRemoteConfig(settings map[string]interface{}, commandsEnabled *bool) { + commandAuthorityApplied := true if commandsEnabled != nil { - a.applyRemoteConfig(*commandsEnabled) + _, commandAuthorityApplied = a.applyRemoteConfig(*commandsEnabled) } if interval, ok := remoteDurationSetting(settings, "interval"); ok && interval > 0 { intervalChanged := false @@ -1480,7 +1496,7 @@ func (a *Agent) ApplyRemoteConfig(settings map[string]interface{}, commandsEnabl } } a.applyRemoteAvailabilityTargets(settings) - if remoteConfigAppliedWithoutRestart(settings) && remoteconfig.HasAppliedDesiredConfig(commandsEnabled, settings) { + if commandAuthorityApplied && remoteConfigAppliedWithoutRestart(settings) && remoteconfig.HasAppliedDesiredConfig(commandsEnabled, settings) { metadata, err := remoteconfig.BuildDesiredConfigMetadata(commandsEnabled, settings) if err != nil { a.logger.Warn().Err(err).Msg("Failed to derive refreshed managed configuration fingerprint") @@ -1521,31 +1537,44 @@ func remoteConfigAppliedWithoutRestart(settings map[string]interface{}) bool { return true } -// applyRemoteConfig applies server-side command execution overrides. -func (a *Agent) applyRemoteConfig(commandsEnabled bool) { +// applyRemoteConfig applies server-side command execution overrides without +// exceeding the immutable authority provisioned when this process started. +// It returns the effective state and whether the requested state was accepted. +func (a *Agent) applyRemoteConfig(commandsEnabled bool) (bool, bool) { var ( - clientToStart *CommandClient - commandCfg Config - shouldStop bool + clientToStart *CommandClient + commandCfg Config + shouldStop bool + logRejected bool + effectiveState, authorityAccepted = ResolveCommandAuthority(a.commandAuthorityProfile, commandsEnabled) ) a.configMu.Lock() - a.cfg.EnableCommands = commandsEnabled + if !authorityAccepted && !a.commandAuthorityWarningSeen { + a.commandAuthorityWarningSeen = true + logRejected = true + } + if !commandsEnabled { + a.commandAuthorityWarningSeen = false + } + a.cfg.EnableCommands = effectiveState commandCfg = a.cfg a.configMu.Unlock() a.commandClientMu.Lock() currentlyEnabled := a.commandClient != nil switch { - case commandsEnabled && !currentlyEnabled: + case effectiveState && !currentlyEnabled: clientToStart = a.newCommandClient(commandCfg, a.agentID, a.hostname, a.platform, a.agentVersion) a.commandClient = clientToStart - case !commandsEnabled && currentlyEnabled: + case !effectiveState && currentlyEnabled: shouldStop = true } a.commandClientMu.Unlock() - if clientToStart != nil { + if logRejected { + a.logger.Warn().Msg("Ignored remote command enablement because this monitoring-only runtime was not provisioned with command authority; reinstall with an explicit command profile") + } else if clientToStart != nil { if a.startCommandClient(clientToStart) { a.logger.Info().Msg("Server enabled command execution - starting command client") } else { @@ -1555,6 +1584,8 @@ func (a *Agent) applyRemoteConfig(commandsEnabled bool) { a.logger.Info().Msg("Server disabled command execution - stopping command client") a.stopCommandClient(true) } + + return effectiveState, authorityAccepted } func remoteBoolSetting(settings map[string]interface{}, key string) (bool, bool) { diff --git a/internal/hostagent/agent_new_test.go b/internal/hostagent/agent_new_test.go index 291c00be25..7589a42ce0 100644 --- a/internal/hostagent/agent_new_test.go +++ b/internal/hostagent/agent_new_test.go @@ -585,11 +585,12 @@ func TestAgentApplyRemoteConfigDoesNotLogUnchangedSettings(t *testing.T) { ReportIP: "192.0.2.20", DisableCeph: true, }, - interval: 30 * time.Second, - reportIP: "192.0.2.20", - remoteConfigChanged: make(chan struct{}, 1), - logger: logger, - commandClient: commandClient, + interval: 30 * time.Second, + reportIP: "192.0.2.20", + remoteConfigChanged: make(chan struct{}, 1), + logger: logger, + commandClient: commandClient, + commandAuthorityProfile: CommandAuthorityCommandCapable, } settings := map[string]interface{}{ "interval": "30s", @@ -610,6 +611,35 @@ func TestAgentApplyRemoteConfigDoesNotLogUnchangedSettings(t *testing.T) { } } +func TestAgentApplyRemoteConfigCannotPromoteMonitoringOnlyRuntime(t *testing.T) { + var logs bytes.Buffer + desiredCommands := true + prior := &agentshost.ConfigFingerprint{Version: "host-agent-config/v1", Hash: "sha256:monitoring-only"} + agent := &Agent{ + cfg: Config{ + AppliedConfig: prior, + }, + logger: zerolog.New(&logs).Level(zerolog.InfoLevel), + newCommandClient: NewCommandClient, + commandAuthorityProfile: CommandAuthorityMonitoringOnly, + } + + agent.ApplyRemoteConfig(nil, &desiredCommands) + + if agent.cfg.EnableCommands { + t.Fatal("monitoring-only runtime accepted remote command promotion") + } + if agent.commandClient != nil { + t.Fatal("monitoring-only runtime created a command client") + } + if agent.cfg.AppliedConfig == nil || agent.cfg.AppliedConfig.Hash != prior.Hash { + t.Fatalf("rejected command promotion advanced applied fingerprint: %+v", agent.cfg.AppliedConfig) + } + if !strings.Contains(logs.String(), "was not provisioned with command authority") { + t.Fatalf("rejected command promotion omitted actionable warning: %s", logs.String()) + } +} + func TestNewRejectsInvalidDeploySSHUser(t *testing.T) { mc := &mockCollector{ hostInfoFn: func(context.Context) (*gohost.InfoStat, error) { diff --git a/internal/hostagent/command_authority.go b/internal/hostagent/command_authority.go new file mode 100644 index 0000000000..3d18957bcd --- /dev/null +++ b/internal/hostagent/command_authority.go @@ -0,0 +1,50 @@ +package hostagent + +import ( + "fmt" + "strings" +) + +// CommandAuthorityProfile is the local, install-time ceiling for command +// execution. It is intentionally independent of the server's desired command +// state so remote configuration cannot widen a monitoring-only process. +type CommandAuthorityProfile string + +const ( + // CommandAuthorityLegacy preserves update continuity for installations + // created before an explicit local authority marker existed. It may follow + // the server's desired command state and is not a least-privilege claim. + CommandAuthorityLegacy CommandAuthorityProfile = "legacy" + // CommandAuthorityMonitoringOnly permanently disables command execution for + // the lifetime of the process, regardless of server configuration. + CommandAuthorityMonitoringOnly CommandAuthorityProfile = "monitoring-only" + // CommandAuthorityCommandCapable permits the server to disable and re-enable + // command execution because the operator provisioned that authority locally. + CommandAuthorityCommandCapable CommandAuthorityProfile = "command-capable" +) + +// NormalizeCommandAuthorityProfile validates the local profile. An absent +// marker is deliberately legacy for upgrade compatibility; fresh installers +// always write an explicit profile. +func NormalizeCommandAuthorityProfile(raw string) (CommandAuthorityProfile, error) { + switch CommandAuthorityProfile(strings.ToLower(strings.TrimSpace(raw))) { + case "", CommandAuthorityLegacy: + return CommandAuthorityLegacy, nil + case CommandAuthorityMonitoringOnly: + return CommandAuthorityMonitoringOnly, nil + case CommandAuthorityCommandCapable: + return CommandAuthorityCommandCapable, nil + default: + return "", fmt.Errorf("invalid command authority profile %q", raw) + } +} + +// ResolveCommandAuthority applies a desired command state within the local +// profile. accepted is false only when a monitoring-only profile rejects a +// request to enable commands. +func ResolveCommandAuthority(profile CommandAuthorityProfile, desired bool) (effective bool, accepted bool) { + if profile == CommandAuthorityMonitoringOnly && desired { + return false, false + } + return desired, true +} diff --git a/internal/hostagent/command_authority_test.go b/internal/hostagent/command_authority_test.go new file mode 100644 index 0000000000..d751e7b55f --- /dev/null +++ b/internal/hostagent/command_authority_test.go @@ -0,0 +1,45 @@ +package hostagent + +import "testing" + +func TestNormalizeCommandAuthorityProfile(t *testing.T) { + for _, tc := range []struct { + input string + want CommandAuthorityProfile + }{ + {input: "", want: CommandAuthorityLegacy}, + {input: "legacy", want: CommandAuthorityLegacy}, + {input: " MONITORING-ONLY ", want: CommandAuthorityMonitoringOnly}, + {input: "command-capable", want: CommandAuthorityCommandCapable}, + } { + got, err := NormalizeCommandAuthorityProfile(tc.input) + if err != nil || got != tc.want { + t.Fatalf("NormalizeCommandAuthorityProfile(%q) = (%q, %v), want %q", tc.input, got, err, tc.want) + } + } + if _, err := NormalizeCommandAuthorityProfile("root"); err == nil { + t.Fatal("invalid command authority profile was accepted") + } +} + +func TestResolveCommandAuthority(t *testing.T) { + for _, tc := range []struct { + name string + profile CommandAuthorityProfile + desired bool + effective bool + accepted bool + }{ + {name: "legacy enable", profile: CommandAuthorityLegacy, desired: true, effective: true, accepted: true}, + {name: "command capable enable", profile: CommandAuthorityCommandCapable, desired: true, effective: true, accepted: true}, + {name: "monitoring disable", profile: CommandAuthorityMonitoringOnly, desired: false, effective: false, accepted: true}, + {name: "monitoring rejects enable", profile: CommandAuthorityMonitoringOnly, desired: true, effective: false, accepted: false}, + } { + t.Run(tc.name, func(t *testing.T) { + effective, accepted := ResolveCommandAuthority(tc.profile, tc.desired) + if effective != tc.effective || accepted != tc.accepted { + t.Fatalf("ResolveCommandAuthority() = (%v, %v), want (%v, %v)", effective, accepted, tc.effective, tc.accepted) + } + }) + } +} diff --git a/internal/hostagent/hostagent_coverage_test.go b/internal/hostagent/hostagent_coverage_test.go index 8fa17d8e12..f5ac5ef7c5 100644 --- a/internal/hostagent/hostagent_coverage_test.go +++ b/internal/hostagent/hostagent_coverage_test.go @@ -16,10 +16,11 @@ func TestAgent_ApplyRemoteConfig_Direct(t *testing.T) { mc := &mockCollector{} logger := zerolog.Nop() a, _ := New(Config{ - APIToken: "token", - PulseURL: "https://pulse", - Collector: mc, - Logger: &logger, + APIToken: "token", + PulseURL: "https://pulse", + EnableCommands: true, + Collector: mc, + Logger: &logger, }) if a != nil { a.applyRemoteConfig(true) @@ -127,10 +128,11 @@ func TestAgent_ApplyRemoteConfig_DefersCommandStartWithoutRunContext(t *testing. mc := &mockCollector{} logger := zerolog.Nop() a, _ := New(Config{ - APIToken: "token", - PulseURL: "https://pulse", - Collector: mc, - Logger: &logger, + APIToken: "token", + PulseURL: "https://pulse", + EnableCommands: true, + Collector: mc, + Logger: &logger, runCommandClientFn: func(_ *CommandClient, ctx context.Context) error { started <- struct{}{} <-ctx.Done() diff --git a/internal/hostagent/privilege.go b/internal/hostagent/privilege.go index c4c3f05d23..f57d9dc759 100644 --- a/internal/hostagent/privilege.go +++ b/internal/hostagent/privilege.go @@ -14,11 +14,12 @@ import ( // a least-privilege install configures are in effect. On Windows Geteuid is // always -1, so RunningAsRoot stays false and ServiceUser carries the account // name; the server renders the profile descriptively rather than judging it. -func collectPrivilegeStatus() *agentshost.PrivilegeStatus { +func collectPrivilegeStatus(commandAuthority CommandAuthorityProfile) *agentshost.PrivilegeStatus { status := &agentshost.PrivilegeStatus{ - RunningAsRoot: os.Geteuid() == 0, - SmartctlHelper: strings.TrimSpace(os.Getenv("PULSE_SMARTCTL_PATH")) != "", - PctHelper: strings.TrimSpace(os.Getenv("PULSE_PCT_PATH")) != "", + RunningAsRoot: os.Geteuid() == 0, + CommandAuthority: string(commandAuthority), + SmartctlHelper: strings.TrimSpace(os.Getenv("PULSE_SMARTCTL_PATH")) != "", + PctHelper: strings.TrimSpace(os.Getenv("PULSE_PCT_PATH")) != "", } if current, err := user.Current(); err == nil { status.ServiceUser = strings.TrimSpace(current.Username) diff --git a/internal/hostagent/privilege_test.go b/internal/hostagent/privilege_test.go index 4e999f33c1..23693290cb 100644 --- a/internal/hostagent/privilege_test.go +++ b/internal/hostagent/privilege_test.go @@ -10,7 +10,7 @@ func TestCollectPrivilegeStatusReportsProcessFacts(t *testing.T) { t.Setenv("PULSE_SMARTCTL_PATH", "") t.Setenv("PULSE_PCT_PATH", "") - status := collectPrivilegeStatus() + status := collectPrivilegeStatus(CommandAuthorityMonitoringOnly) if status == nil { t.Fatal("collectPrivilegeStatus returned nil") } @@ -20,6 +20,9 @@ func TestCollectPrivilegeStatusReportsProcessFacts(t *testing.T) { if status.ServiceUser == "" { t.Fatal("ServiceUser is empty") } + if status.CommandAuthority != string(CommandAuthorityMonitoringOnly) { + t.Fatalf("CommandAuthority = %q", status.CommandAuthority) + } if status.SmartctlHelper || status.PctHelper { t.Fatalf("helper flags set without overrides: %+v", status) } @@ -29,7 +32,7 @@ func TestCollectPrivilegeStatusReportsHelperOverrides(t *testing.T) { t.Setenv("PULSE_SMARTCTL_PATH", "/usr/local/lib/pulse-agent/smartctl-helper") t.Setenv("PULSE_PCT_PATH", "/usr/local/lib/pulse-agent/pct-helper") - status := collectPrivilegeStatus() + status := collectPrivilegeStatus(CommandAuthorityCommandCapable) if !status.SmartctlHelper || !status.PctHelper { t.Fatalf("helper overrides not reported: %+v", status) } diff --git a/internal/models/models.go b/internal/models/models.go index 1e428f1481..968614e501 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -428,10 +428,11 @@ type AgentModuleStatus struct { // Informational only — a non-root agent is an intentional profile, not a // health defect. type AgentPrivilegeStatus struct { - RunningAsRoot bool `json:"runningAsRoot"` - ServiceUser string `json:"serviceUser,omitempty"` - SmartctlHelper bool `json:"smartctlHelper,omitempty"` - PctHelper bool `json:"pctHelper,omitempty"` + RunningAsRoot bool `json:"runningAsRoot"` + ServiceUser string `json:"serviceUser,omitempty"` + CommandAuthority string `json:"commandAuthority,omitempty"` + SmartctlHelper bool `json:"smartctlHelper,omitempty"` + PctHelper bool `json:"pctHelper,omitempty"` } func (h Host) NormalizeCollections() Host { diff --git a/internal/models/state_host_test.go b/internal/models/state_host_test.go index 90710adb20..7593919690 100644 --- a/internal/models/state_host_test.go +++ b/internal/models/state_host_test.go @@ -57,6 +57,25 @@ func TestUpsertHost(t *testing.T) { } } +func TestUpsertHostPreservesReportedCommandAuthority(t *testing.T) { + state := NewState() + state.UpsertHost(Host{ + ID: "host-authority", + AgentPrivilege: &AgentPrivilegeStatus{ + RunningAsRoot: true, + CommandAuthority: "monitoring-only", + }, + }) + + hosts := state.GetHosts() + if len(hosts) != 1 || hosts[0].AgentPrivilege == nil { + t.Fatalf("reported privilege missing from state: %+v", hosts) + } + if got := hosts[0].AgentPrivilege.CommandAuthority; got != "monitoring-only" { + t.Fatalf("command authority = %q, want monitoring-only", got) + } +} + func TestGetHosts_Copy(t *testing.T) { state := NewState() diff --git a/internal/monitoring/agent_fleet_doctor.go b/internal/monitoring/agent_fleet_doctor.go index a36a50fefa..778a3a1875 100644 --- a/internal/monitoring/agent_fleet_doctor.go +++ b/internal/monitoring/agent_fleet_doctor.go @@ -39,6 +39,7 @@ const ( AgentFleetReasonCredentialExpired = "agent_credential_expired" AgentFleetReasonCredentialUnlisted = "agent_credential_registry_stale" AgentFleetReasonExecScopeMissing = "agent_exec_scope_missing" + AgentFleetReasonExecScopeExcess = "agent_exec_scope_excess" AgentFleetReasonDuplicateInstallation = "duplicate_host_agent_installation" AgentFleetActionAllowReenroll = "allow_reenroll" @@ -120,10 +121,13 @@ type AgentFleetDiagnosticModule struct { // not a Reason: a least-privilege install is an intentional hardening choice, // so it must never degrade the agent's health status by itself. type AgentFleetDiagnosticPrivilege struct { - RunningAsRoot bool `json:"runningAsRoot"` - ServiceUser string `json:"serviceUser,omitempty"` - SmartctlHelper bool `json:"smartctlHelper,omitempty"` - PctHelper bool `json:"pctHelper,omitempty"` + RunningAsRoot bool `json:"runningAsRoot"` + ServiceUser string `json:"serviceUser,omitempty"` + CommandAuthority string `json:"commandAuthority,omitempty"` + CredentialKnown bool `json:"credentialKnown,omitempty"` + CredentialExec bool `json:"credentialExec,omitempty"` + SmartctlHelper bool `json:"smartctlHelper,omitempty"` + PctHelper bool `json:"pctHelper,omitempty"` } type AgentFleetDiagnosticReason struct { @@ -432,7 +436,7 @@ func diagnoseAgentFleetSubject( Version: subject.version, AgentUpdate: agentFleetUpdateForSubject(subject), AgentModules: agentFleetModulesForSubject(subject), - Privilege: agentFleetPrivilegeForSubject(subject), + Privilege: agentFleetPrivilegeForSubject(subject, tokenInventory), } if !subject.lastSeen.IsZero() { result.LastSeen = subject.lastSeen.UnixMilli() @@ -570,8 +574,9 @@ func diagnoseAgentCredential(subject agentFleetSubject, inventory agentFleetToke return nil } if record, ok := inventory.active[tokenID]; ok { + reasons := make([]AgentFleetDiagnosticReason, 0, 2) if subject.host != nil && subject.host.CommandsEnabled && !record.HasScope(config.ScopeAgentExec) { - return []AgentFleetDiagnosticReason{{ + reasons = append(reasons, AgentFleetDiagnosticReason{ Code: AgentFleetReasonExecScopeMissing, Severity: AgentFleetStatusCritical, Message: "The agent is configured to accept Pulse commands, but its credential does not grant command execution.", @@ -579,9 +584,29 @@ func diagnoseAgentCredential(subject agentFleetSubject, inventory agentFleetToke "Agent command execution: enabled", "Credential scope: agent:exec missing", }, - }} + }) } - return nil + if subject.host != nil && subject.host.AgentPrivilege != nil { + authority := strings.TrimSpace(subject.host.AgentPrivilege.CommandAuthority) + hasExec := record.HasScope(config.ScopeAgentExec) + switch { + case authority == "monitoring-only" && hasExec: + reasons = append(reasons, AgentFleetDiagnosticReason{ + Code: AgentFleetReasonExecScopeExcess, + Severity: AgentFleetStatusWarning, + Message: "This monitoring-only runtime still uses a credential that grants command execution.", + Evidence: []string{"Local command authority: monitoring-only", "Credential scope: agent:exec present"}, + }) + case authority == "command-capable" && !hasExec && !subject.host.CommandsEnabled: + reasons = append(reasons, AgentFleetDiagnosticReason{ + Code: AgentFleetReasonExecScopeMissing, + Severity: AgentFleetStatusWarning, + Message: "This runtime was installed as command-capable, but its credential cannot re-enable commands.", + Evidence: []string{"Local command authority: command-capable", "Credential scope: agent:exec missing"}, + }) + } + } + return reasons } if _, ok := inventory.expired[tokenID]; ok { return []AgentFleetDiagnosticReason{{ @@ -930,17 +955,23 @@ func agentFleetUpdateForSubject(subject agentFleetSubject) *AgentFleetDiagnostic } } -func agentFleetPrivilegeForSubject(subject agentFleetSubject) *AgentFleetDiagnosticPrivilege { +func agentFleetPrivilegeForSubject(subject agentFleetSubject, inventory agentFleetTokenInventory) *AgentFleetDiagnosticPrivilege { if subject.host == nil || subject.host.AgentPrivilege == nil { return nil } privilege := subject.host.AgentPrivilege - return &AgentFleetDiagnosticPrivilege{ - RunningAsRoot: privilege.RunningAsRoot, - ServiceUser: strings.TrimSpace(privilege.ServiceUser), - SmartctlHelper: privilege.SmartctlHelper, - PctHelper: privilege.PctHelper, + result := &AgentFleetDiagnosticPrivilege{ + RunningAsRoot: privilege.RunningAsRoot, + ServiceUser: strings.TrimSpace(privilege.ServiceUser), + CommandAuthority: strings.TrimSpace(privilege.CommandAuthority), + SmartctlHelper: privilege.SmartctlHelper, + PctHelper: privilege.PctHelper, } + if record, ok := inventory.active[strings.TrimSpace(subject.tokenID)]; ok { + result.CredentialKnown = true + result.CredentialExec = record.HasScope(config.ScopeAgentExec) + } + return result } func agentFleetModulesForSubject(subject agentFleetSubject) []AgentFleetDiagnosticModule { diff --git a/internal/monitoring/agent_fleet_doctor_test.go b/internal/monitoring/agent_fleet_doctor_test.go index 1b00b7bf83..165315cb19 100644 --- a/internal/monitoring/agent_fleet_doctor_test.go +++ b/internal/monitoring/agent_fleet_doctor_test.go @@ -247,6 +247,68 @@ func TestAgentFleetDiagnosticsAcceptsCommandCredentialWithExecScope(t *testing.T } } +func TestAgentFleetDiagnosticsWarnsWhenMonitoringRuntimeHasExecCredential(t *testing.T) { + now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC) + monitor := newAgentFleetDoctorTestMonitor(t) + monitor.config.APITokens = []config.APITokenRecord{{ + ID: "over-scoped-token", + Scopes: []string{config.ScopeAgentReport, config.ScopeAgentExec}, + }} + monitor.state.UpsertHost(models.Host{ + ID: "agent-monitoring-authority", + Hostname: "monitoring-node", + Platform: "linux", + Status: "online", + LastSeen: now, + AgentVersion: "6.2.0", + TokenID: "over-scoped-token", + AgentPrivilege: &models.AgentPrivilegeStatus{ + RunningAsRoot: true, + CommandAuthority: "monitoring-only", + }, + }) + + agent := requireAgentDiagnostic(t, monitor.GetAgentFleetDiagnostics("6.2.0", now), "agent-agent-monitoring-authority") + requireReasonCode(t, agent, AgentFleetReasonExecScopeExcess) + if agent.Status != AgentFleetStatusWarning { + t.Fatalf("status = %q, want %q", agent.Status, AgentFleetStatusWarning) + } + if agent.Privilege == nil || !agent.Privilege.CredentialKnown || !agent.Privilege.CredentialExec { + t.Fatalf("credential authority was not projected into privilege diagnostics: %+v", agent.Privilege) + } +} + +func TestAgentFleetDiagnosticsWarnsWhenCommandCapableRuntimeCannotBeReenabled(t *testing.T) { + now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC) + monitor := newAgentFleetDoctorTestMonitor(t) + monitor.config.APITokens = []config.APITokenRecord{{ + ID: "monitoring-token", + Scopes: []string{config.ScopeAgentReport, config.ScopeAgentConfigRead}, + }} + monitor.state.UpsertHost(models.Host{ + ID: "agent-command-authority", + Hostname: "command-node", + Platform: "linux", + Status: "online", + LastSeen: now, + AgentVersion: "6.2.0", + TokenID: "monitoring-token", + AgentPrivilege: &models.AgentPrivilegeStatus{ + RunningAsRoot: true, + CommandAuthority: "command-capable", + }, + }) + + agent := requireAgentDiagnostic(t, monitor.GetAgentFleetDiagnostics("6.2.0", now), "agent-agent-command-authority") + reason := requireReasonCode(t, agent, AgentFleetReasonExecScopeMissing) + if reason.Severity != AgentFleetStatusWarning || agent.Status != AgentFleetStatusWarning { + t.Fatalf("reason/status = %q/%q, want warning/warning", reason.Severity, agent.Status) + } + if agent.Privilege == nil || !agent.Privilege.CredentialKnown || agent.Privilege.CredentialExec { + t.Fatalf("credential authority was not projected into privilege diagnostics: %+v", agent.Privilege) + } +} + func TestAgentFleetDiagnosticsDoesNotValidateSyntheticMockCredentials(t *testing.T) { now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) mustSetMockEnabled(t, true) @@ -377,10 +439,11 @@ func TestAgentFleetDiagnosticsSurfacesPrivilegeProfileWithoutDegradingHealth(t * IntervalSeconds: 30, AgentVersion: "6.2.0", AgentPrivilege: &models.AgentPrivilegeStatus{ - RunningAsRoot: false, - ServiceUser: "pulse-agent", - SmartctlHelper: true, - PctHelper: false, + RunningAsRoot: false, + ServiceUser: "pulse-agent", + CommandAuthority: "monitoring-only", + SmartctlHelper: true, + PctHelper: false, }, }) @@ -391,6 +454,7 @@ func TestAgentFleetDiagnosticsSurfacesPrivilegeProfileWithoutDegradingHealth(t * t.Fatal("privilege profile missing from diagnostics") } if agent.Privilege.RunningAsRoot || agent.Privilege.ServiceUser != "pulse-agent" || + agent.Privilege.CommandAuthority != "monitoring-only" || !agent.Privilege.SmartctlHelper || agent.Privilege.PctHelper { t.Fatalf("privilege profile = %+v", agent.Privilege) } diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index 0ecbd46c08..a776466034 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -3421,10 +3421,11 @@ func convertAgentPrivilegeStatus(value *agentshost.PrivilegeStatus) *models.Agen return nil } return &models.AgentPrivilegeStatus{ - RunningAsRoot: value.RunningAsRoot, - ServiceUser: strings.TrimSpace(value.ServiceUser), - SmartctlHelper: value.SmartctlHelper, - PctHelper: value.PctHelper, + RunningAsRoot: value.RunningAsRoot, + ServiceUser: strings.TrimSpace(value.ServiceUser), + CommandAuthority: strings.TrimSpace(value.CommandAuthority), + SmartctlHelper: value.SmartctlHelper, + PctHelper: value.PctHelper, } } diff --git a/pkg/agents/host/report.go b/pkg/agents/host/report.go index 9add286022..cbcd232dd1 100644 --- a/pkg/agents/host/report.go +++ b/pkg/agents/host/report.go @@ -176,8 +176,9 @@ type AgentInfo struct { // anything, and the server must not treat a non-root agent as unhealthy on // this evidence alone. type PrivilegeStatus struct { - RunningAsRoot bool `json:"runningAsRoot"` - ServiceUser string `json:"serviceUser,omitempty"` + RunningAsRoot bool `json:"runningAsRoot"` + ServiceUser string `json:"serviceUser,omitempty"` + CommandAuthority string `json:"commandAuthority,omitempty"` // SmartctlHelper reports that a PULSE_SMARTCTL_PATH override is in effect, // which a least-privilege install uses to route SMART reads through a // scoped privilege helper. diff --git a/pkg/agents/host/report_test.go b/pkg/agents/host/report_test.go index 3dfdfe838b..f1e0f04151 100644 --- a/pkg/agents/host/report_test.go +++ b/pkg/agents/host/report_test.go @@ -133,10 +133,11 @@ func TestAgentInfoPrivilegeStatusRoundTrip(t *testing.T) { agent := AgentInfo{ ID: "agent-privilege", Privilege: &PrivilegeStatus{ - RunningAsRoot: false, - ServiceUser: "pulse-agent", - SmartctlHelper: true, - PctHelper: false, + RunningAsRoot: false, + ServiceUser: "pulse-agent", + CommandAuthority: "monitoring-only", + SmartctlHelper: true, + PctHelper: false, }, } encoded, err := json.Marshal(agent) @@ -150,6 +151,7 @@ func TestAgentInfoPrivilegeStatusRoundTrip(t *testing.T) { if decoded.Privilege == nil || decoded.Privilege.RunningAsRoot || decoded.Privilege.ServiceUser != "pulse-agent" || + decoded.Privilege.CommandAuthority != "monitoring-only" || !decoded.Privilege.SmartctlHelper || decoded.Privilege.PctHelper { t.Fatalf("privilege round trip = %+v", decoded.Privilege) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f490d6368d..194d905ec9 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -16,6 +16,7 @@ param ( [bool]$EnableProxmox = $false, [string]$ProxmoxType = "", [bool]$EnableCommands = $false, + [string]$CommandAuthority = $env:PULSE_COMMAND_AUTHORITY, [bool]$Insecure = $false, [bool]$Uninstall = $false, [string]$CACertPath = $env:PULSE_CACERT, @@ -941,6 +942,38 @@ if (-not [string]::IsNullOrWhiteSpace($ServerVersion) -and -not [string]::IsNull # --- Install Binary --- Write-Host "Installing binary..." -ForegroundColor Cyan +# Resolve the local command-authority ceiling before replacing an existing +# service. An unmarked service is explicitly legacy-compatible so an upgrade +# cannot silently revoke a command state that was enabled remotely. +$ExistingService = Get-CimInstance Win32_Service -Filter "Name='$AgentName'" -ErrorAction SilentlyContinue +$ExistingServicePath = if ($null -ne $ExistingService) { [string]$ExistingService.PathName } else { "" } +if (-not $PSBoundParameters.ContainsKey('EnableCommands') -and + [string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_COMMANDS) -and + $ExistingServicePath -match '(?i)(?:^|\s)--enable-commands(?:\s|$)') { + $EnableCommands = $true +} +if ([string]::IsNullOrWhiteSpace($CommandAuthority)) { + $authorityMatch = [regex]::Match($ExistingServicePath, '(?i)(?:^|\s)--command-authority(?:=|\s+)["'']?([a-z-]+)') + if ($authorityMatch.Success) { + $CommandAuthority = $authorityMatch.Groups[1].Value + } elseif ($EnableCommands) { + $CommandAuthority = 'command-capable' + } elseif (-not [string]::IsNullOrWhiteSpace($ExistingServicePath)) { + $CommandAuthority = 'legacy' + } else { + $CommandAuthority = 'monitoring-only' + } +} +$CommandAuthority = $CommandAuthority.Trim().ToLowerInvariant() +if ($CommandAuthority -notin @('monitoring-only', 'command-capable', 'legacy')) { + Show-Error "Invalid CommandAuthority '$CommandAuthority'. Expected monitoring-only, command-capable, or legacy." + Exit 1 +} +if ($CommandAuthority -eq 'monitoring-only' -and $EnableCommands) { + Show-Error "EnableCommands conflicts with CommandAuthority monitoring-only." + Exit 1 +} + # Stop existing service if running if (Get-Service $AgentName -ErrorAction SilentlyContinue) { Write-Host "Removing existing $AgentName service..." -ForegroundColor Yellow @@ -992,6 +1025,7 @@ if ($EnableKubernetes) { $ServiceArgs += "--enable-kubernetes" } if ($EnableProxmox) { $ServiceArgs += "--enable-proxmox" } if (-not [string]::IsNullOrWhiteSpace($NormalizedProxmoxType)) { $ServiceArgs += @("--proxmox-type", "`"$NormalizedProxmoxType`"") } if ($EnableCommands) { $ServiceArgs += "--enable-commands" } +$ServiceArgs += @("--command-authority", "`"$CommandAuthority`"") if ($Insecure) { $ServiceArgs += "--insecure" } if (-not [string]::IsNullOrWhiteSpace($CACertPath)) { $ServiceArgs += @("--cacert", "`"$CACertPath`"") } if (-not [string]::IsNullOrWhiteSpace($ServerFingerprint)) { $ServiceArgs += @("--server-fingerprint", "`"$ServerFingerprint`"") } diff --git a/scripts/install.sh b/scripts/install.sh index e57eb8c8df..7783be86af 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -89,6 +89,11 @@ AGENT_ID="" HOSTNAME_OVERRIDE="" REPORT_IP="" ENABLE_COMMANDS="false" +COMMAND_AUTHORITY="${PULSE_COMMAND_AUTHORITY:-}" +COMMAND_AUTHORITY_SOURCE="" +if [[ -n "$COMMAND_AUTHORITY" ]]; then + COMMAND_AUTHORITY_SOURCE="explicit" +fi HEALTH_ADDR="${PULSE_HEALTH_ADDR:-}" HEALTH_ADDR_SET="false" if [[ -n "${PULSE_HEALTH_ADDR+x}" ]]; then @@ -495,6 +500,7 @@ Options: --server-fingerprint Pin the Pulse server leaf certificate for agent connections --observers-file Absolute path to private JSON config for report-only observer Pulse destinations --enable-commands Enable Pulse command execution (disabled by default; required for Patrol actions and Proxmox LXC Docker inventory) + --command-authority Local command ceiling: monitoring-only, command-capable, or legacy --least-privilege Run the agent as the 'pulse-agent' system user instead of root (Linux systemd only) --grant-smart With --least-privilege: exact-command sudoers grant so SMART collection keeps working --grant-pct With --least-privilege: sudoers grant restricted to 'pct list'/'pct df' so Proxmox LXC filesystem capacity keeps working @@ -1050,12 +1056,9 @@ render_systemd_agent_unit() { # dies with "write_id_mapping: Operation not permitted" and Docker in # every unprivileged LXC stays invisible. # - # This is granted for any PVE agent, not only one installed with - # --enable-commands, because command execution can be turned on later - # from the server (applyRemoteConfig) without rewriting this unit. That - # path used to leave the agent able to run commands but unable to - # attach to unprivileged guests, and the failure is only visible at - # debug level. + # The grant exists only for an explicitly command-capable PVE install. + # A monitoring-only unit must not carry dormant privilege in case a + # remote setting later requests command execution. ambient_line=$'\n'"AmbientCapabilities=CAP_SETUID CAP_SETGID" fi @@ -1101,11 +1104,13 @@ systemd_agent_requires_lxc_attach() { systemd_agent_may_attach_lxc } -# Whether this host could ever need lxc-attach, independent of whether command -# execution was enabled at install time. Command execution is also togglable -# from the server after the fact, so the unit has to be provisioned for it up -# front or the later toggle produces a half-working agent. +# Whether this explicitly command-capable host needs lxc-attach. Remote +# configuration cannot promote a monitoring-only process into a command +# runtime, so the unit must not provision dormant attach capabilities. systemd_agent_may_attach_lxc() { + if [[ "$ENABLE_COMMANDS" != "true" ]]; then + return 1 + fi # The least-privilege profile never attaches into guests: pct exec and # command execution stay root-profile features, so it does not get the # CAP_SETUID/CAP_SETGID ambient grant either. @@ -2018,6 +2023,7 @@ build_exec_arg_items() { if [[ -n "$SERVER_FINGERPRINT" ]]; then EXEC_ARG_ITEMS+=(--server-fingerprint "$SERVER_FINGERPRINT"); fi if [[ -n "$OBSERVERS_FILE" ]]; then EXEC_ARG_ITEMS+=(--observers-file "$OBSERVERS_FILE"); fi if [[ "$ENABLE_COMMANDS" == "true" ]]; then EXEC_ARG_ITEMS+=(--enable-commands); fi + EXEC_ARG_ITEMS+=(--command-authority "${COMMAND_AUTHORITY:-legacy}") if [[ "$HEALTH_ADDR_SET" == "true" ]]; then EXEC_ARG_ITEMS+=(--health-addr "$HEALTH_ADDR"); fi if [[ "$ENROLL" == "true" ]]; then EXEC_ARG_ITEMS+=(--enroll); fi if [[ "$KUBE_INCLUDE_ALL_PODS" == "true" ]]; then EXEC_ARG_ITEMS+=(--kube-include-all-pods); fi @@ -2338,6 +2344,13 @@ apply_recovered_agent_arg_value() { if [[ -z "$OBSERVERS_FILE" ]]; then OBSERVERS_FILE="$value"; fi RECOVERED_AGENT_ARG_STATE="true" ;; + command-authority) + if [[ -z "$COMMAND_AUTHORITY_SOURCE" ]]; then + COMMAND_AUTHORITY="$value" + COMMAND_AUTHORITY_SOURCE="recovered" + fi + RECOVERED_AGENT_ARG_STATE="true" + ;; health-addr) if [[ "$HEALTH_ADDR_SET" != "true" ]]; then HEALTH_ADDR="$value" @@ -2385,6 +2398,33 @@ update_connection_state_incomplete() { [[ -z "$PULSE_URL" || -z "$PULSE_TOKEN" || -z "$AGENT_ID" || -z "$HOSTNAME_OVERRIDE" || -z "$CURL_CA_BUNDLE" || -z "$SERVER_FINGERPRINT" || "$INSECURE" != "true" ]] } +resolve_command_authority_profile() { + if [[ -z "$COMMAND_AUTHORITY" ]]; then + if [[ "$ENABLE_COMMANDS" == "true" ]]; then + COMMAND_AUTHORITY="command-capable" + elif [[ "$UPDATE_ONLY" == "true" ]]; then + # Services installed before this marker existed may have been + # remotely promoted. Preserve that explicitly during update; fresh + # monitoring installs are locked below. + COMMAND_AUTHORITY="legacy" + else + COMMAND_AUTHORITY="monitoring-only" + fi + fi + + case "$COMMAND_AUTHORITY" in + monitoring-only|command-capable|legacy) + ;; + *) + fail "Invalid --command-authority value: ${COMMAND_AUTHORITY} (expected monitoring-only, command-capable, or legacy)" "$EXIT_MISSING_ARGS" + ;; + esac + + if [[ "$COMMAND_AUTHORITY" == "monitoring-only" && "$ENABLE_COMMANDS" == "true" ]]; then + fail "--enable-commands conflicts with --command-authority monitoring-only" "$EXIT_MISSING_ARGS" + fi +} + recover_connection_state_from_arg_stream() { local arg="" local pending_key="" @@ -2401,10 +2441,10 @@ recover_connection_state_from_arg_stream() { fi case "$arg" in - --url|--pulse-url|--token|--token-file|--interval|--agent-id|--hostname|--report-ip|--cacert|--server-fingerprint|--observers-file|--health-addr|--state-dir|--kubeconfig|--proxmox-type|--disk-exclude|--disk-include|-url|-pulse-url|-token|-token-file|-interval|-agent-id|-hostname|-report-ip|-cacert|-server-fingerprint|-observers-file|-health-addr|-state-dir|-kubeconfig|-proxmox-type|-disk-exclude|-disk-include) + --url|--pulse-url|--token|--token-file|--interval|--agent-id|--hostname|--report-ip|--cacert|--server-fingerprint|--observers-file|--command-authority|--health-addr|--state-dir|--kubeconfig|--proxmox-type|--disk-exclude|--disk-include|-url|-pulse-url|-token|-token-file|-interval|-agent-id|-hostname|-report-ip|-cacert|-server-fingerprint|-observers-file|-command-authority|-health-addr|-state-dir|-kubeconfig|-proxmox-type|-disk-exclude|-disk-include) pending_key=$(normalize_recovered_agent_arg_key "$arg") ;; - --url=*|--pulse-url=*|--token=*|--token-file=*|--interval=*|--agent-id=*|--hostname=*|--report-ip=*|--cacert=*|--server-fingerprint=*|--observers-file=*|--health-addr=*|--state-dir=*|--kubeconfig=*|--proxmox-type=*|--disk-exclude=*|--disk-include=*|-url=*|-pulse-url=*|-token=*|-token-file=*|-interval=*|-agent-id=*|-hostname=*|-report-ip=*|-cacert=*|-server-fingerprint=*|-observers-file=*|-health-addr=*|-state-dir=*|-kubeconfig=*|-proxmox-type=*|-disk-exclude=*|-disk-include=*) + --url=*|--pulse-url=*|--token=*|--token-file=*|--interval=*|--agent-id=*|--hostname=*|--report-ip=*|--cacert=*|--server-fingerprint=*|--observers-file=*|--command-authority=*|--health-addr=*|--state-dir=*|--kubeconfig=*|--proxmox-type=*|--disk-exclude=*|--disk-include=*|-url=*|-pulse-url=*|-token=*|-token-file=*|-interval=*|-agent-id=*|-hostname=*|-report-ip=*|-cacert=*|-server-fingerprint=*|-observers-file=*|-command-authority=*|-health-addr=*|-state-dir=*|-kubeconfig=*|-proxmox-type=*|-disk-exclude=*|-disk-include=*) key="${arg%%=*}" value="${arg#*=}" apply_recovered_agent_arg_value "$key" "$value" @@ -2990,6 +3030,7 @@ while [[ $# -gt 0 ]]; do --server-fingerprint) SERVER_FINGERPRINT="$2"; shift 2 ;; --observers-file) OBSERVERS_FILE="$2"; shift 2 ;; --enable-commands) ENABLE_COMMANDS="true"; shift ;; + --command-authority) COMMAND_AUTHORITY="$2"; COMMAND_AUTHORITY_SOURCE="explicit"; shift 2 ;; --least-privilege) LEAST_PRIVILEGE="true"; shift ;; --grant-smart) GRANT_SMART="true"; shift ;; --grant-pct) GRANT_PCT="true"; shift ;; @@ -3101,6 +3142,8 @@ if [[ "$UPDATE_ONLY" == "true" || "$UNINSTALL" == "true" ]]; then fi fi +resolve_command_authority_profile + if [[ -z "$STATE_DIR" || "$STATE_DIR" != /* || "$STATE_DIR" == "/" || "$STATE_DIR" == *$'\r'* || "$STATE_DIR" == *$'\n'* ]]; then fail "Recovered Pulse Agent state directory is invalid." "$EXIT_MISSING_ARGS" diff --git a/scripts/installtests/install_ps1_test.go b/scripts/installtests/install_ps1_test.go index 6be6a3704f..2a962e6abd 100644 --- a/scripts/installtests/install_ps1_test.go +++ b/scripts/installtests/install_ps1_test.go @@ -326,9 +326,14 @@ func TestInstallPS1PreservesCommandExecutionParity(t *testing.T) { script := string(content) required := []string{ `[bool]$EnableCommands = $false,`, + `[string]$CommandAuthority = $env:PULSE_COMMAND_AUTHORITY,`, `if (-not $PSBoundParameters.ContainsKey('EnableCommands') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_ENABLE_COMMANDS)) {`, `$EnableCommands = Parse-Bool $env:PULSE_ENABLE_COMMANDS $EnableCommands`, `if ($EnableCommands) { $ServiceArgs += "--enable-commands" }`, + `$ServiceArgs += @("--command-authority", "` + "`" + `"$CommandAuthority` + "`" + `"")`, + `$CommandAuthority = 'monitoring-only'`, + `$CommandAuthority = 'legacy'`, + `$CommandAuthority = 'command-capable'`, } for _, needle := range required { if !strings.Contains(script, needle) { diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index d9b18656a2..e50a356d4a 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -278,12 +278,10 @@ func TestInstallSHAllowsProxmoxCommandAgentLXCAttach(t *testing.T) { } } -// A PVE agent installed without --enable-commands must still be provisioned for -// lxc-attach, because command execution can be enabled later from the server -// without rewriting the unit. Without CAP_SETUID, lxc-attach cannot write -// /proc//uid_map for unprivileged guests, so Docker inside every -// unprivileged LXC silently disappears from the Proxmox page. -func TestInstallSHGrantsLXCAttachCapabilitiesForAnyPVEAgent(t *testing.T) { +// Only a PVE agent explicitly installed with --enable-commands may receive the +// lxc-attach capability grant. A monitoring-only unit must not carry dormant +// CAP_SETUID/CAP_SETGID authority for a future remote toggle. +func TestInstallSHGrantsLXCAttachCapabilitiesOnlyForCommandPVEAgent(t *testing.T) { content, err := os.ReadFile(repoFile("scripts", "install.sh")) if err != nil { t.Fatalf("read install.sh: %v", err) @@ -292,6 +290,7 @@ func TestInstallSHGrantsLXCAttachCapabilitiesForAnyPVEAgent(t *testing.T) { script := string(content) required := []string{ `systemd_agent_may_attach_lxc() {`, + `if [[ "$ENABLE_COMMANDS" != "true" ]]; then`, `if [[ "$ENABLE_PROXMOX" != "true" ]]; then`, `if systemd_agent_may_attach_lxc; then`, `AmbientCapabilities=CAP_SETUID CAP_SETGID`, @@ -303,9 +302,62 @@ func TestInstallSHGrantsLXCAttachCapabilitiesForAnyPVEAgent(t *testing.T) { } } - if strings.Contains(script, `systemd_agent_may_attach_lxc`) && - !strings.Contains(script, "command execution can be turned on later") { - t.Fatal("install.sh must document why the capability grant is not gated on ENABLE_COMMANDS") + if strings.Contains(script, "command execution can be turned on later") { + t.Fatal("install.sh still provisions dormant attach capabilities for remote command promotion") + } +} + +func TestRenderedSystemdUnitDoesNotPregrantCommandCapabilities(t *testing.T) { + for _, tc := range []struct { + name string + enableCommands bool + enableProxmox bool + proxmoxType string + leastPrivilege bool + wantCaps bool + }{ + {name: "monitoring PVE", enableProxmox: true, proxmoxType: "pve"}, + {name: "explicit command PVE", enableCommands: true, enableProxmox: true, proxmoxType: "pve", wantCaps: true}, + {name: "PBS command profile", enableCommands: true, enableProxmox: true, proxmoxType: "pbs"}, + {name: "least privilege PVE", enableCommands: true, enableProxmox: true, proxmoxType: "pve", leastPrivilege: true}, + } { + t.Run(tc.name, func(t *testing.T) { + unitPath := filepath.Join(t.TempDir(), "pulse-agent.service") + script := ` +set -euo pipefail +ENABLE_COMMANDS="` + strconv.FormatBool(tc.enableCommands) + `" +ENABLE_PROXMOX="` + strconv.FormatBool(tc.enableProxmox) + `" +PROXMOX_TYPE="` + tc.proxmoxType + `" +LEAST_PRIVILEGE="` + strconv.FormatBool(tc.leastPrivilege) + `" +GRANT_SMART="false" +GRANT_PCT="false" +SYSTEMD_ENV_LINES="" +` + extractInstallShellFunction(t, "systemd_agent_requires_lxc_attach") + ` +` + extractInstallShellFunction(t, "systemd_agent_may_attach_lxc") + ` +` + extractInstallShellFunction(t, "render_systemd_agent_unit") + ` +render_systemd_agent_unit "` + unitPath + `" "/usr/local/bin/pulse-agent" "--url https://pulse" "network-online.target" "" "root" "" +` + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("render systemd unit: %v\n%s", err, out) + } + unit, err := os.ReadFile(unitPath) + if err != nil { + t.Fatalf("read rendered unit: %v", err) + } + text := string(unit) + hasCaps := strings.Contains(text, "AmbientCapabilities=CAP_SETUID CAP_SETGID") + if hasCaps != tc.wantCaps { + t.Fatalf("rendered capability grant = %v, want %v:\n%s", hasCaps, tc.wantCaps, text) + } + if tc.wantCaps { + if !strings.Contains(text, "NoNewPrivileges=false") || !strings.Contains(text, "RestrictSUIDSGID=false") { + t.Fatalf("command-capable PVE unit omitted required attach relaxations:\n%s", text) + } + } else if !strings.Contains(text, "NoNewPrivileges=true") || !strings.Contains(text, "RestrictSUIDSGID=true") { + t.Fatalf("monitoring unit did not retain hardening:\n%s", text) + } + }) } } @@ -852,7 +904,7 @@ func TestInstallSHSupportsSavedStateUpdateMode(t *testing.T) { `recover_connection_state_from_arg_stream`, `recover_token_from_default_agent_token_file() {`, `normalize_recovered_agent_arg_key() {`, - `-url|-pulse-url|-token|-token-file|-interval|-agent-id|-hostname|-report-ip|-cacert|-server-fingerprint|-observers-file|-health-addr|-state-dir|-kubeconfig|-proxmox-type|-disk-exclude|-disk-include)`, + `-url|-pulse-url|-token|-token-file|-interval|-agent-id|-hostname|-report-ip|-cacert|-server-fingerprint|-observers-file|-command-authority|-health-addr|-state-dir|-kubeconfig|-proxmox-type|-disk-exclude|-disk-include)`, `--enable-host|-enable-host|--enable-host=true|-enable-host=true)`, `recover_connection_state_from_env_stream`, `recovered_connection_state_ready() {`,