From 5b2e3778434891d9bfef7ce47b5d1e52e0b07162 Mon Sep 17 00:00:00 2001 From: Pulse Test Date: Sun, 30 Aug 2026 00:38:44 +0100 Subject: [PATCH] Expose secure agent runtime posture --- docs/AGENT_SECURITY.md | 33 +++ docs/UNIFIED_AGENT.md | 23 ++ .../v6/internal/subsystems/agent-lifecycle.md | 70 +++++ .../v6/internal/subsystems/api-contracts.md | 53 ++-- .../v6/internal/subsystems/monitoring.md | 8 + .../internal/subsystems/security-privacy.md | 7 + .../internal/subsystems/storage-recovery.md | 6 + frontend-modern/browser-verification.json | 47 ++-- frontend-modern/public/docs/AGENT_SECURITY.md | 138 +++++++++- frontend-modern/public/docs/UNIFIED_AGENT.md | 52 ++++ .../api/__tests__/agentDiagnostics.test.ts | 28 ++ frontend-modern/src/api/agentDiagnostics.ts | 37 +++ .../InfrastructureAgentDoctorPage.tsx | 259 ++++++++++++++++++ .../InfrastructureAgentDoctorPage.test.tsx | 156 +++++++++++ .../infrastructureAgentDoctorModel.test.ts | 184 +++++++++++++ .../infrastructureAgentUpdateCommandsModel.ts | 176 +++++++++++- .../api/action_runner_credentials_test.go | 26 ++ internal/api/agent_fleet_doctor.go | 111 +++++++- internal/api/agent_fleet_doctor_test.go | 57 ++++ internal/api/agenttokens/install.go | 27 +- internal/api/agenttokens/install_test.go | 82 ++++++ internal/api/contract_test.go | 12 +- internal/api/security_regression_test.go | 30 ++ internal/hostagent/privilege.go | 1 + internal/hostagent/privilege_test.go | 6 +- internal/models/models.go | 1 + internal/monitoring/agent_fleet_doctor.go | 36 ++- .../monitoring/agent_fleet_doctor_test.go | 2 + internal/monitoring/monitor_agents.go | 1 + .../monitoring/monitor_host_agents_test.go | 2 + pkg/agents/host/report.go | 4 + pkg/agents/host/report_test.go | 2 + 32 files changed, 1605 insertions(+), 72 deletions(-) create mode 100644 frontend-modern/src/components/Settings/__tests__/InfrastructureAgentDoctorPage.test.tsx diff --git a/docs/AGENT_SECURITY.md b/docs/AGENT_SECURITY.md index 2fc1d4ed9..3d9d56d89 100644 --- a/docs/AGENT_SECURITY.md +++ b/docs/AGENT_SECURITY.md @@ -200,6 +200,29 @@ The safe runner accepts only versioned host update/storage-cleanup, Proxmox guest lifecycle, and container lifecycle/update requests. Generic shell, `exec`, unrestricted `read_file`, and deploy operations are rejected. +**Settings → Infrastructure → Agent Doctor** presents this boundary as +evidence, not an inferred security grade. The collector reports whether it is +root, its service user and local command-authority ceiling, and whether the +typed helper, SMART helper, and `pct` helper are configured. Pulse separately +joins the current tenant's token inventory and admitted command sessions to +show whether the collector credential is known and execution-scoped, whether +a host-bound runner credential is active, and whether a compatible +`action-runner` / `typed_actions.v1` session is actually connected. Missing +evidence remains unknown; collector health never proves remediation readiness. + +Runner enrollment is one host at a time. The issuance request must resolve to +exactly one non-conflicted monitored agent ID and normalized hostname in the +current tenant. The returned secret is shown once and held only in the open +Agent Doctor page's memory; it is not written to browser storage, a URL, a +diagnostic report, or an installer command. The generated handoff first prompts +for the secret through `/dev/tty` into the root-owned +`/etc/pulse-agent-runner/token` file with mode `0600`, then passes only that +file path through `--action-token-file`. Issuing again rotates the credential +for that tenant/host binding: Pulse durably replaces the previous record as one +transaction and restores it if persistence fails. A successful rotation makes +the previous secret invalid, so complete the new token-file handoff before +expecting a disconnected runner to reconnect. + The existing combined collector command path remains available only as the explicit legacy/full-trust migration profile. It is not part of the typed helper/runner security claim and will remain until supported command-enabled @@ -217,6 +240,16 @@ install.sh --safe-profile-rollback ... Inspection makes no changes. Apply snapshots collector/helper files and identity before switching profiles, and rollback restores that snapshot. The separately installed action runner is not changed by either operation. +Inspection reports platform support, the current unit user and groups, ambient +capabilities, collector-binary owner and mode, enabled provider flags, helper +and collector-command state, independent runner presence, and the calculated +typed-helper target and degraded Docker/action differences. Apply is supported +only on reviewed standard Linux systemd hosts: it retains the monitoring token +and agent identity, lowers the collector to monitoring-only, removes legacy +sudo/Docker-group/ambient authority, requires collector health, helper socket +health, and declared server registration before commit, and automatically +restores the exact snapshot on failure. Ordinary `--update` preserves the +installed profile and never performs this migration. The older `--least-privilege` profile without `--enable-privileged-helper` remains available for compatibility. Its trade-offs are documented below. diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index 6f4bda0ac..7968c0114 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -111,6 +111,19 @@ without removing the collector. The runner accepts only the documented typed host, Proxmox guest, and container operations; shell, generic exec, unrestricted file reads, and deploy requests remain forbidden. +Agent Doctor shows the reported collector service user, command-authority +ceiling, collector credential scope, and typed-helper configuration separately +from the tenant- and host-bound action-runner credential and connected-session +state. It does not treat a healthy collector as proof that actions are ready. +For an eligible Linux systemd host, enrollment returns one credential reveal +kept only in the open page's memory. Start the generated `/dev/tty` prompt, +copy and paste the secret into that prompt to write the private root-owned +token file, and then run the installer command, which carries only +`--action-token-file ` in its arguments. Re-enrollment atomically +replaces the prior credential for the same tenant and canonical agent ID; a +failed persistence write retains the previous credential, while a successful +rotation invalidates it. + Use `--safe-profile-inspect` to report the current profile and calculated differences without changing the host. `--safe-profile-apply` performs the explicit collector/helper migration and retains a rollback snapshot; @@ -118,6 +131,16 @@ explicit collector/helper migration and retains a rollback snapshot; Linux systemd and fail closed elsewhere. Appliance, non-systemd, Windows, and macOS installs remain explicit legacy/full-trust profiles until their own runtime and migration boundaries are qualified. +Inspection includes unit identity/groups, ambient capabilities, binary +ownership/mode, provider flags, helper/command state, independent runner +presence, and the expected telemetry/action degradation. Apply preserves the +collector token and agent identity, commits only after local health, helper +socket, and server registration checks, and restores the exact prior +collector/helper profile on failure. It never changes a separately installed +runner, and ordinary `--update` never implies a profile migration. The secure +runtime separation remains a proposed hardening lane; the product-wide default +does not change until representative real Linux hosts pass migration, +activation, rollback, provider-parity, and action-session qualification. On Linux, the host module automatically checks for `virsh`. When the agent can open the default libvirt connection read-only, defined domains appear as VM diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index ba39e9f75..2e75cfbd0 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -815,6 +815,44 @@ only while Agent Doctor is open, enriches canonical connection-ledger rows by stable `connectionId`, and retains ledger-only fallback rows when structured evidence is absent. +Secure-runtime posture on that read model is factual and split by authority +source. The collector-authored report supplies effective-root state, service +user, local `commandAuthority`, and configured typed/SMART/`pct` helpers. The +server adds only evidence it can independently observe: whether the reporting +token is present and carries `agent:exec`, whether the current organization has +an active action-runner credential bound to the canonical agent ID, and whether +the organization-scoped live-session registry contains that agent under the +closed `action-runner` role and `typed_actions.v1` capability. Connected +runner version, connection time, binding version, receipt protocol, preflight +protocol, and Docker-observation protocol remain separate optional facts. +Absence is not projected as safe, and neither collector liveness nor a healthy +fleet verdict may stand in for action-session readiness. + +Agent Doctor may issue or rotate an action-runner credential only after the +selected diagnostic proves a non-root, monitoring-only collector with a known +non-exec credential and typed helper, resolves to a standard-looking Linux +systemd installation, and has one complete canonical agent ID/hostname pair. +The operator-only endpoint re-resolves that pair against exactly one live, +non-conflicted, non-integration host in the request tenant. The resulting +credential has only `agent:exec`, the explicit `action-runner` role, +`typed_actions.v1`, binding version 1, organization ID, canonical agent ID, and +normalized hostname. Rotation replaces only the earlier action-runner record +for that organization/agent binding; the token inventory is saved atomically, +and a failed save restores the complete previous inventory before the secret +can be returned. + +The raw secret is a one-time UI reveal, not a diagnostic field. The page keeps +it in component memory only, permits at most one successful issuance for that +target in the page session, and clears it explicitly or on navigation. The +host handoff prompts through `/dev/tty`, writes +`/etc/pulse-agent-runner/token` under a root-owned `0700` directory with file +mode `0600`, and puts only `--action-token-file` plus canonical identity in the +installer command. Secrets must not enter URLs, browser storage, diagnostics, +command arguments, or logs. A successful rotation invalidates the prior +credential immediately; the UI therefore blocks enrollment while a compatible +runner session is already connected and presents rotation only for an issued +but disconnected binding. + Repair entries remain handoffs to existing lifecycle operations: `copy_upgrade_command` renders a local operator command and `allow_reenroll` invokes the existing removed-agent flow. Agent Doctor exposes @@ -6720,6 +6758,38 @@ the only server delivery path: it rejects non-Linux targets, serves the exact separate helper artifact with checksum plus detached signatures, and requires a published signed release asset when no local binary exists. +### Safe collector profile migration is an explicit transaction + +`scripts/install.sh --safe-profile-inspect` is a standalone no-write action. +It reports platform support, detected current profile, unit user and groups, +ambient capabilities, collector-binary owner/mode, host/Docker/Kubernetes/ +Proxmox provider flags, typed-helper and collector-command state, independent +runner presence, and the calculated typed-helper target plus Docker/action +degradation. Those host-local facts are broader than the remote Agent Doctor +projection and must not be guessed from it. + +`--safe-profile-apply` is supported only for an existing collector on reviewed +standard Linux systemd hosts. It snapshots exact collector and helper binaries, +units, legacy sudoers/wrappers, service enable/active state, Docker-group +membership, credential-directory metadata, monitoring token files, agent ID, +and connection state before mutation. It then installs root-owned collector +and helper artifacts, lowers the collector to monitoring-only, removes legacy +sudo, Docker-group, and ambient authority, and commits an atomic current/prior +profile record only after collector health, helper-socket health, and declared +server registration succeed. Any pre-commit failure restores the snapshot; +`--safe-profile-rollback` restores the retained committed snapshot explicitly. +Rollback may restore only privilege recorded in that explicit prior profile and +must never invent sudo, Docker-group, ambient, root, or command authority. The +action runner is independently installed and is never snapshotted, stopped, +rewritten, or restored by this transaction. Ordinary `--update` preserves the +installed profile and cannot trigger migration. + +This remains candidate-lane evidence, not a product-wide default flip. The +secure collector profile stays explicit until representative real Linux hosts +qualify migration and failure rollback, typed-helper update activation, +provider parity and documented degradation, and separately credentialed live +action sessions. Unsupported platforms continue to fail closed on migration. + ### Command dispatch is context-honest and abandoned executions are canceled The agent command transport now refuses to dispatch work its caller has diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 58913640d..847ab3cc5 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -32,15 +32,19 @@ and its two signature sidecars. The action runner has a parallel Linux-only, rate-limited signed-binary contract at `GET|HEAD /download/pulse-agent-runner?arch=linux-*`; it never falls back to a collector or helper asset. `POST -/api/agents/action-runner/credential` is an admin and `actions:execute` -operation that accepts one canonical monitored host identity and returns a -new, separately persisted `agent:exec` credential bound to organization, -agent ID, normalized hostname, runtime role `action-runner`, and capability -`typed_actions.v1`. Monitoring credentials cannot call the issuance route or -authenticate an action-runner session, and action-runner credentials are not -collector report/config credentials. Unknown request fields, ambiguous or -conflicted host identities, and persistence failures fail before any usable -credential is returned. +/api/agents/action-runner/credential` is an operator-only admin, +`settings:write`, and `actions:execute` operation. It accepts one canonical +monitored `agentId` / `hostname` pair and returns a new, separately persisted +`agent:exec` credential bound to organization, agent ID, normalized hostname, +runtime role `action-runner`, binding version 1, and capability +`typed_actions.v1`. The route re-resolves the pair against exactly one live, +non-conflicted, non-integration host in the request tenant. Issuing again +atomically replaces only the earlier action-runner record for that tenant and +agent ID; a persistence failure restores the complete token inventory and +returns no usable new secret. Monitoring credentials cannot call the issuance +route or authenticate an action-runner session, and action-runner credentials +cannot report, read agent configuration, or manage collectors. Unknown request +fields and ambiguous, mismatched, or conflicted host identities fail closed. The API runtime is decomposed along production domain boundaries so Go can compile and execute domain qualification packages concurrently. Shared tenant @@ -10042,14 +10046,29 @@ execution-profile projection; neither external clients nor model output can select or widen the surface. The agent fleet diagnostics payload (`GET /api/agents/diagnostics`) now -carries an optional per-agent `privilege` object — `runningAsRoot`, -`serviceUser`, `smartctlHelper`, `pctHelper` — mirrored by the frontend -transport in `frontend-modern/src/api/agentDiagnostics.ts` -(`AgentFleetDiagnosticPrivilege`). The field is descriptive fleet evidence: -it appears only when the agent reported a profile, it never carries -credentials or paths, and consumers must not derive health status from it. -The unified agent report contract (`pkg/agents/host/report.go`) gains the -matching agent-authored `privilege` block with the same fields. +carries an optional per-agent `privilege` object mirrored by +`frontend-modern/src/api/agentDiagnostics.ts` +(`AgentFleetDiagnosticPrivilege`). The agent-authored portion is +`runningAsRoot`, `serviceUser`, `commandAuthority`, `typedHelper`, +`smartctlHelper`, and `pctHelper`. The server may add `credentialKnown` and +`credentialExec` from the reporting-token inventory plus +`actionRunnerCredentialIssued`, `actionRunnerCredentialActive`, +`actionRunnerRuntimeRole`, `actionRunnerCapability`, +`actionRunnerBindingVersion`, `actionRunnerConnected`, +`actionRunnerVersion`, `actionRunnerConnectedAt`, and the receipt, preflight, +and Docker-observation protocol versions from the organization-scoped +credential and admitted-session registries. The unified agent report contract +(`pkg/agents/host/report.go`) carries only the agent-authored subset. + +These fields are descriptive fleet evidence, not authorization and not a +health reason. They never contain raw credentials, credential paths, helper +paths, unit groups/capabilities, or binary ownership. Consumers must not infer +those host-local facts, safe-profile completion, or action readiness from an +absent field, collector health, or the mere existence of a runner credential. +Agent Doctor may show the raw `token` returned by the issuance response only as +a one-time component-memory reveal; it must not persist that value in browser +storage, URLs, diagnostics, installer commands, or process arguments. The +generated installer handoff carries only a private `--action-token-file` path. ### Proxmox node network inventory is an optional canonical facet diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 72ba8968a..fcd18410f 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -38,6 +38,14 @@ 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. +Action-runner posture is a server-side join, not collector-reported monitoring +truth. The collector may report only that its typed local helper is configured; +Agent Doctor separately overlays the current tenant's host-bound runner-token +inventory and admitted command-session inventory. A runner is connected only +when the canonical agent ID matches an admitted `action-runner` session with +the closed `typed_actions.v1` capability. Credential presence, expiry, +connection, version, and protocol facts remain distinct, and none may be +inferred from collector health or helper configuration. 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 573d9bdbc..38e1062f8 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -65,6 +65,13 @@ shell/exec, unrestricted `read_file`, deploy, and trusted-origin bypasses are forbidden. The legacy combined command channel remains a disclosed full-trust migration boundary only until runner enrollment and live session parity are qualified; it is not safe-profile authority. +Action-runner issuance and rotation are one durable host-bound transition per +organization and canonical agent ID. Re-issuance replaces the prior runner +record even when the monitored hostname has changed, returns the new plaintext +secret only after persistence succeeds, and restores the complete prior token +inventory if persistence fails. A successful rotation invalidates the previous +secret immediately; it never widens the collector credential or turns a +monitoring session into an action session. Own Pulse's canonical privacy disclosures, outbound usage-data boundary, and the security-facing settings surfaces that expose authentication posture, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index d781108eb..0db15bd2c 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -34,6 +34,12 @@ preserves collector identity and installation files only; it must not rewrite storage/recovery evidence or reinterpret a restored legacy collector profile as recovery success. Typed `host.storage_cleanup` remains governed remediation, not storage-recovery authority inferred from the credential itself. +Runner credential rotation follows the shared token-inventory commit boundary: +failed persistence restores the complete prior inventory and returns no new +secret, while successful persistence replaces the previous organization/agent +binding and makes its secret unusable. That rollback protects restart-time +credential truth only; it is not a customer backup, recovery point, restore +operation, or storage-cleanup grant. The helper-backed collector updater likewise retains only one fixed last-known-good executable and an identity-bound activation receipt. That binary rollback is agent lifecycle state, not a Pulse storage snapshot, diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 1c6a901e2..6321ee66d 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,28 +1,26 @@ { "version": 1, - "base_sha": "d607d5cf4616e52b2fc7a576846dbe7204e9a7cf", - "verified_at": "2026-08-29T23:04:06Z", + "base_sha": "28be70071e8a7c2d0b9663f5d2eab5f5ba2f6528", + "verified_at": "2026-08-29T23:37:43Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/docker/DockerPageSurface.tsx", - "frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx", - "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx" + "frontend-modern/src/api/agentDiagnostics.ts", + "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx", + "frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts" ], "content_sha256": { - "frontend-modern/src/features/docker/DockerPageSurface.tsx": "013f32dc0201ed993a68763a53e88374fe9b08892e321806fb5dbb9aa51bf77f", - "frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx": "c8ab6ff8abdf7c61a5c75f0d024a682c1b83a10efa46278c87b946afe654ef60", - "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "2158aed352e98b2fc1f0d308b494d0a8d387aefc28fbf90bbc92a498e49e161a" + "frontend-modern/src/api/agentDiagnostics.ts": "1e3c207615fd27fedf811b40830789f779b80b570085c54970cfb277f54eb07f", + "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "0053b3fa0be0fbf463a6569c4fef4d977401647fe7c717a27543eb023a69e8ff", + "frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts": "5157fd513d3d149fdd844e0e807d0d73879661ab165cf2850efa8286a888ecbf" }, "routes": [ - "/proxmox/overview", - "/proxmox/backups/date", - "/docker/overview", - "/kubernetes/overview" + "/settings/infrastructure/agent-doctor", + "/settings/infrastructure/agent-doctor?agents=host-node-mock-cluster-4-pve22" ], "viewports": [ { - "width": 1280, - "height": 720 + "width": 1440, + "height": 1000 }, { "width": 390, @@ -30,18 +28,17 @@ } ], "states": [ - "Proxmox Overview with the node table and paginated guest inventory populated from only the source-scoped Overview owner", - "Proxmox Backups with PBS servers, backup-health summary, activity chart, filters, and workload rows composed from the existing guest snapshot plus PBS rows", - "Docker Overview with provider-owned hosts and containers populated from one source-scoped inventory request", - "Kubernetes Overview with provider-owned clusters, deployments, and pods populated from one source-scoped inventory request", - "all four provider states at desktop and narrow viewport widths" + "full fleet summary and expanded legacy Linux agent with explicit safe-profile inspect and apply guidance", + "eligible monitoring-only collector with typed-helper configuration and no action-runner credential or session", + "one-time host-bound action-runner credential reveal with private token prompt and separate runner installer handoff", + "credential-cleared page-session state with repeat issuance disabled", + "eligible safe collector at desktop and narrow viewport widths" ], "interactions": [ - "reloaded Proxmox Overview with browser request tracing and verified that only its ten paginated source=proxmox Overview requests ran, with no inactive Storage, Backups, Replication, Ceph, or Mail hydration", - "navigated from Proxmox Overview to Backups and verified that the transition added exactly one source=pbs request without repeating the paginated guest inventory", - "reloaded Docker Overview and verified that its only inventory request included source=docker and populated the host and container surfaces", - "reloaded Kubernetes Overview and verified that its only inventory request included source=kubernetes and populated the cluster and workload surfaces", - "repeated the provider route and transition checks at 390 by 844 and verified the tables, navigation, filters, status summaries, and workload rows remained present", - "reset the temporary viewport override and closed the verification tab" + "expanded a legacy Linux agent and copied the standalone safe-profile inspection command", + "issued an isolated non-persistent action-runner credential fixture and verified the normalized host identity, closed runtime role, and typed capability before reveal", + "verified the revealed secret was absent from the private token-file command, installer command, URL, and diagnostic report", + "cleared the one-time credential and verified the secret disappeared while repeat issuance stayed disabled for the page session", + "verified the 390 by 844 layout had no document overflow, checked the rendered pixels, reset the viewport override, and removed the temporary request fixture" ] } diff --git a/frontend-modern/public/docs/AGENT_SECURITY.md b/frontend-modern/public/docs/AGENT_SECURITY.md index 5dda2eb37..3d9d56d89 100644 --- a/frontend-modern/public/docs/AGENT_SECURITY.md +++ b/frontend-modern/public/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 @@ -139,6 +143,117 @@ user and token. ## Least-Privilege Agent Profile +For a standard Linux systemd host, the stronger opt-in profile is: + +```bash +install.sh --least-privilege --enable-privileged-helper ... +``` + +This profile keeps the networked collector unprivileged and keeps both the +collector and helper binaries root-owned. Its installer token lives under the +root-owned `/etc/pulse-agent` directory with `root:pulse-agent` group-read +access; mutable identity, buffering, and enrolled monitoring-token state remain +under the collector-owned state directory. The collector is not added to the +rootful Docker group and cannot enable command execution. Automatic binary +replacement uses a separate typed transaction: the collector downloads and +self-tests a signed artifact inside its fixed quarantine, while the helper +revalidates ownership, digest, ELF shape, and signature before promoting it to +root-only staging and atomically activating it. A failed process restart asks +the helper to restore the identity-bound last-known-good binary; there is no +collector-writable direct-replacement fallback. + +Exceptional telemetry crosses `/run/pulse-agent/helper.sock` to a separate +root process. The socket admits only the `pulse-agent` UID, and the helper has +no Pulse URL, API token, or network namespace. Its protocol exposes bounded, +versioned SMART and Proxmox LXC filesystem snapshots, not a shell, executable +path, device path, VMID, environment, or caller-selected arguments. The helper +service keeps `PrivateNetwork=true`, `RestrictAddressFamilies=AF_UNIX`, +`NoNewPrivileges=true`, `ProtectSystem=strict`, and `ProtectHome=true`. +`PrivateDevices` is intentionally not enabled because SMART needs the host +block devices. If the helper is missing, incompatible, or rejects a request, +only the affected telemetry disappears; the collector does not fall back to +sudo, root, or a broader local command path. + +The typed-helper profile cannot be combined with `--grant-smart` or +`--grant-pct`. Rootful Docker-socket monitoring is also unavailable because +membership in the Docker group is root-equivalent; API monitoring or a +separately scoped rootless runtime socket is required instead. The profile is +currently explicit rather than the installer default. Its inspect, apply, and +rollback transaction is implemented for Linux systemd, but representative +live-host migration and helper update staging/activation/rollback exercises, +container-runtime parity, and appliance qualification are still required +before the profile can become the general default. + +Monitoring never implies remediation. On the supported Linux systemd profile, +an operator may separately enroll the typed action runner: + +```bash +install.sh --least-privilege --enable-privileged-helper \ + --enable-action-runner --action-token-file /root/pulse-runner.token ... +``` + +Create that token through the authenticated action-runner credential endpoint +for the exact monitored host; do not reuse the collector token. The installer +keeps the runner binary, service, credential, health record, and receipts +root-owned. Disabling or uninstalling the runner leaves monitoring active. +The safe runner accepts only versioned host update/storage-cleanup, Proxmox +guest lifecycle, and container lifecycle/update requests. Generic shell, +`exec`, unrestricted `read_file`, and deploy operations are rejected. + +**Settings → Infrastructure → Agent Doctor** presents this boundary as +evidence, not an inferred security grade. The collector reports whether it is +root, its service user and local command-authority ceiling, and whether the +typed helper, SMART helper, and `pct` helper are configured. Pulse separately +joins the current tenant's token inventory and admitted command sessions to +show whether the collector credential is known and execution-scoped, whether +a host-bound runner credential is active, and whether a compatible +`action-runner` / `typed_actions.v1` session is actually connected. Missing +evidence remains unknown; collector health never proves remediation readiness. + +Runner enrollment is one host at a time. The issuance request must resolve to +exactly one non-conflicted monitored agent ID and normalized hostname in the +current tenant. The returned secret is shown once and held only in the open +Agent Doctor page's memory; it is not written to browser storage, a URL, a +diagnostic report, or an installer command. The generated handoff first prompts +for the secret through `/dev/tty` into the root-owned +`/etc/pulse-agent-runner/token` file with mode `0600`, then passes only that +file path through `--action-token-file`. Issuing again rotates the credential +for that tenant/host binding: Pulse durably replaces the previous record as one +transaction and restores it if persistence fails. A successful rotation makes +the previous secret invalid, so complete the new token-file handoff before +expecting a disconnected runner to reconnect. + +The existing combined collector command path remains available only as the +explicit legacy/full-trust migration profile. It is not part of the typed +helper/runner security claim and will remain until supported command-enabled +installs have a runner enrollment path and live action-session parity has been +qualified. + +Safe-profile conversion is always deliberate: + +```bash +install.sh --safe-profile-inspect ... +install.sh --safe-profile-apply ... +install.sh --safe-profile-rollback ... +``` + +Inspection makes no changes. Apply snapshots collector/helper files and +identity before switching profiles, and rollback restores that snapshot. The +separately installed action runner is not changed by either operation. +Inspection reports platform support, the current unit user and groups, ambient +capabilities, collector-binary owner and mode, enabled provider flags, helper +and collector-command state, independent runner presence, and the calculated +typed-helper target and degraded Docker/action differences. Apply is supported +only on reviewed standard Linux systemd hosts: it retains the monitoring token +and agent identity, lowers the collector to monitoring-only, removes legacy +sudo/Docker-group/ambient authority, requires collector health, helper socket +health, and declared server registration before commit, and automatically +restores the exact snapshot on failure. Ordinary `--update` preserves the +installed profile and never performs this migration. + +The older `--least-privilege` profile without `--enable-privileged-helper` +remains available for compatibility. Its trade-offs are documented below. + On standard Linux systemd hosts, `install.sh --least-privilege` is a supported alternative to the root profile. It runs the service as a dedicated `pulse-agent` system user (nologin shell, owning only its state directory and @@ -172,9 +287,10 @@ platforms (TrueNAS, Synology, QNAP, Unraid) and non-systemd init systems keep the root profile; the installer refuses `--least-privilege` there rather than silently falling back to root. -`--update` preserves an existing least-privilege profile and its grants -without the flags being repeated. Uninstall removes the sudoers file and -helpers; the inert system user is left behind deliberately. +`--update` preserves either installed least-privilege profile without the flags +being repeated. Uninstall removes the typed helper socket/service and its +installer credential directory, or the legacy sudoers file and wrappers; the +inert system user is left behind deliberately. ## Supply-Chain Boundary diff --git a/frontend-modern/public/docs/UNIFIED_AGENT.md b/frontend-modern/public/docs/UNIFIED_AGENT.md index 2c1e1b9e1..7968c0114 100644 --- a/frontend-modern/public/docs/UNIFIED_AGENT.md +++ b/frontend-modern/public/docs/UNIFIED_AGENT.md @@ -90,6 +90,58 @@ curl -fsSL http://:7655/install.sh | \ - **Auto-Update**: Automatically updates when a new version is released - **Multi-Platform**: Linux, macOS, Windows support +The opt-in Linux typed-helper collector profile keeps auto-update enabled +without making its root-owned binary writable by the service account. The +collector downloads and self-tests the signed artifact in a fixed +collector-owned quarantine; the no-network helper revalidates it, promotes it +to root-only staging, atomically activates it, and owns identity-bound rollback +if restart fails. There is no fallback to direct unprivileged replacement. +This transaction is covered by unit, race, release-build, and installer +contract tests; live-host restart/health and rollback qualification remains +required before the profile becomes the general default. +See [Agent Security](AGENT_SECURITY.md#least-privilege-agent-profile). + +On Linux systemd, the safe monitoring profile and remediation lifecycle are +separate install choices. `--least-privilege --enable-privileged-helper` +selects the opt-in unprivileged collector and no-network helper. Adding +`--enable-action-runner --action-token-file ` installs the +root-owned runner with a separately issued, host-bound action credential. +`--disable-action-runner` and `--uninstall-action-runner` remove remediation +without removing the collector. The runner accepts only the documented typed +host, Proxmox guest, and container operations; shell, generic exec, +unrestricted file reads, and deploy requests remain forbidden. + +Agent Doctor shows the reported collector service user, command-authority +ceiling, collector credential scope, and typed-helper configuration separately +from the tenant- and host-bound action-runner credential and connected-session +state. It does not treat a healthy collector as proof that actions are ready. +For an eligible Linux systemd host, enrollment returns one credential reveal +kept only in the open page's memory. Start the generated `/dev/tty` prompt, +copy and paste the secret into that prompt to write the private root-owned +token file, and then run the installer command, which carries only +`--action-token-file ` in its arguments. Re-enrollment atomically +replaces the prior credential for the same tenant and canonical agent ID; a +failed persistence write retains the previous credential, while a successful +rotation invalidates it. + +Use `--safe-profile-inspect` to report the current profile and calculated +differences without changing the host. `--safe-profile-apply` performs the +explicit collector/helper migration and retains a rollback snapshot; +`--safe-profile-rollback` restores it. These commands are proven only for +Linux systemd and fail closed elsewhere. Appliance, non-systemd, Windows, and +macOS installs remain explicit legacy/full-trust profiles until their own +runtime and migration boundaries are qualified. +Inspection includes unit identity/groups, ambient capabilities, binary +ownership/mode, provider flags, helper/command state, independent runner +presence, and the expected telemetry/action degradation. Apply preserves the +collector token and agent identity, commits only after local health, helper +socket, and server registration checks, and restores the exact prior +collector/helper profile on failure. It never changes a separately installed +runner, and ordinary `--update` never implies a profile migration. The secure +runtime separation remains a proposed hardening lane; the product-wide default +does not change until representative real Linux hosts pass migration, +activation, rollback, provider-parity, and action-session qualification. + On Linux, the host module automatically checks for `virsh`. When the agent can open the default libvirt connection read-only, defined domains appear as VM workloads under that host. Collection uses libvirt's bounded bulk statistics diff --git a/frontend-modern/src/api/__tests__/agentDiagnostics.test.ts b/frontend-modern/src/api/__tests__/agentDiagnostics.test.ts index 04e5f3d95..61ddf1a7e 100644 --- a/frontend-modern/src/api/__tests__/agentDiagnostics.test.ts +++ b/frontend-modern/src/api/__tests__/agentDiagnostics.test.ts @@ -54,4 +54,32 @@ describe('AgentDiagnosticsAPI', () => { summary: { total: 0, healthy: 0, warning: 0, critical: 0, removed: 0 }, }); }); + + it('issues a separately scoped host-bound action-runner credential', async () => { + mockedApiFetchJSON.mockResolvedValueOnce({ + token: 'runner-secret', + tokenId: 'token-1', + organizationId: 'org-1', + agentId: 'host-1', + hostname: 'host-1.local', + runtimeRole: 'action-runner', + actionCapability: 'typed_actions.v1', + }); + + await expect( + AgentDiagnosticsAPI.issueActionRunnerCredential({ + agentId: 'host-1', + hostname: 'host-1.local', + name: 'host-1 action runner', + }), + ).resolves.toMatchObject({ token: 'runner-secret', agentId: 'host-1' }); + expect(mockedApiFetchJSON).toHaveBeenCalledWith('/api/agents/action-runner/credential', { + method: 'POST', + body: JSON.stringify({ + agentId: 'host-1', + hostname: 'host-1.local', + name: 'host-1 action runner', + }), + }); + }); }); diff --git a/frontend-modern/src/api/agentDiagnostics.ts b/frontend-modern/src/api/agentDiagnostics.ts index d13c5781c..df1eeb021 100644 --- a/frontend-modern/src/api/agentDiagnostics.ts +++ b/frontend-modern/src/api/agentDiagnostics.ts @@ -54,8 +54,36 @@ export interface AgentFleetDiagnosticPrivilege { commandAuthority?: 'monitoring-only' | 'command-capable' | 'legacy' | string; credentialKnown?: boolean; credentialExec?: boolean; + typedHelper?: boolean; smartctlHelper?: boolean; pctHelper?: boolean; + actionRunnerCredentialIssued?: boolean; + actionRunnerCredentialActive?: boolean; + actionRunnerRuntimeRole?: string; + actionRunnerCapability?: string; + actionRunnerBindingVersion?: string; + actionRunnerConnected?: boolean; + actionRunnerVersion?: string; + actionRunnerConnectedAt?: number; + actionRunnerReceiptProtocol?: number; + actionRunnerPreflightProtocol?: number; + actionRunnerDockerObservationProtocol?: number; +} + +export interface ActionRunnerCredentialRequest { + agentId: string; + hostname: string; + name?: string; +} + +export interface ActionRunnerCredentialResponse { + token: string; + tokenId: string; + organizationId: string; + agentId: string; + hostname: string; + runtimeRole: 'action-runner'; + actionCapability: 'typed_actions.v1'; } export interface AgentFleetAgentDiagnostic { @@ -130,4 +158,13 @@ export class AgentDiagnosticsAPI { agents: response.agents ?? [], }; } + + static issueActionRunnerCredential( + request: ActionRunnerCredentialRequest, + ): Promise { + return apiFetchJSON('/api/agents/action-runner/credential', { + method: 'POST', + body: JSON.stringify(request), + }); + } } diff --git a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx index 0a2f8bcae..d35a80dd0 100644 --- a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx @@ -15,12 +15,17 @@ import { copyToClipboard } from '@/utils/clipboard'; import { formatRelativeTime } from '@/utils/format'; import { notificationStore } from '@/stores/notifications'; import { MonitoringAPI } from '@/api/monitoring'; +import { AgentDiagnosticsAPI } from '@/api/agentDiagnostics'; import { getUnifiedAgentClipboardCopyErrorMessage, getUnifiedAgentClipboardCopySuccessMessage, } from '@/utils/unifiedAgentInventoryPresentation'; import { formatInfrastructureAgentDoctorReport, + buildActionRunnerInstallCommand, + buildActionRunnerTokenFileCommand, + buildSafeCollectorApplyCommand, + buildSafeCollectorInspectCommand, getInfrastructureAgentDoctorUninstallHandoff, summarizeInfrastructureAgentDoctorTargets, type InfrastructureAgentDoctorStatus, @@ -192,6 +197,61 @@ export const InfrastructureAgentDoctorPage: Component + >(new Set()); + const [runnerCredentialReveal, setRunnerCredentialReveal] = createSignal<{ + targetKey: string; + token: string; + } | null>(null); + + const issueActionRunnerCredential = async (target: InfrastructureAgentDoctorTarget) => { + if ( + !target.actionRunnerCredentialEligible || + !target.actionRunnerAgentId || + !target.actionRunnerHostname + ) { + return; + } + setRunnerCredentialReveal(null); + setRunnerCredentialPendingKey(target.key); + try { + const response = await AgentDiagnosticsAPI.issueActionRunnerCredential({ + agentId: target.actionRunnerAgentId, + hostname: target.actionRunnerHostname, + name: `${target.displayName} action runner`, + }); + const normalizedHostname = (value: string) => value.trim().toLowerCase().replace(/\.+$/, ''); + if ( + response.agentId !== target.actionRunnerAgentId || + normalizedHostname(response.hostname) !== normalizedHostname(target.actionRunnerHostname) || + response.runtimeRole !== 'action-runner' || + response.actionCapability !== 'typed_actions.v1' || + !response.token + ) { + throw new Error( + 'Pulse returned an unexpected action-runner credential identity or authority.', + ); + } + setRunnerCredentialReveal({ targetKey: target.key, token: response.token }); + setIssuedRunnerCredentialKeys((previous) => new Set(previous).add(target.key)); + notificationStore.success('Action-runner credential issued. Save it now. It is shown once.'); + try { + await props.onRetryDiagnostics?.(); + } catch { + notificationStore.error( + 'The credential was issued, but Pulse could not refresh its runner posture.', + ); + } + } catch (error) { + notificationStore.error( + error instanceof Error ? error.message : 'Failed to issue action-runner credential.', + ); + } finally { + setRunnerCredentialPendingKey(''); + } + }; const allowReenroll = async (target: InfrastructureAgentDoctorTarget) => { const separator = target.connectionId.indexOf(':'); const type = separator > 0 ? target.connectionId.slice(0, separator) : ''; @@ -552,6 +612,205 @@ export const InfrastructureAgentDoctorPage: Component

+
+
+ Security posture + + + Safe collector confirmed + + +
+ +

{target.safeProfileGuidance}

+
+ +
+
+
+ Inspect current service profile +
+
+ + void copyCommand( + buildSafeCollectorInspectCommand({ + pulseUrl: operations.selectedAgentUrl(), + insecure: operations.insecureMode(), + customCaPath: operations.customCaPath(), + }), + ) + } + label="Copy safe-profile inspection command" + /> +
+                                        
+                                          {buildSafeCollectorInspectCommand({
+                                            pulseUrl: operations.selectedAgentUrl(),
+                                            insecure: operations.insecureMode(),
+                                            customCaPath: operations.customCaPath(),
+                                          })}
+                                        
+                                      
+
+
+
+
+ Apply reviewed safe collector profile +
+
+ + void copyCommand( + buildSafeCollectorApplyCommand({ + pulseUrl: operations.selectedAgentUrl(), + insecure: operations.insecureMode(), + customCaPath: operations.customCaPath(), + }), + ) + } + label="Copy safe-profile apply command" + /> +
+                                        
+                                          {buildSafeCollectorApplyCommand({
+                                            pulseUrl: operations.selectedAgentUrl(),
+                                            insecure: operations.insecureMode(),
+                                            customCaPath: operations.customCaPath(),
+                                          })}
+                                        
+                                      
+
+
+
+
+ 0}> +
    + + {(fact) =>
  • {fact}
  • } +
    +
+
+ +

+ Action runner: {target.actionRunnerCredentialBlockReason} +

+
+ } + > + + +
+ + + {(reveal) => { + const tokenFileCommand = () => buildActionRunnerTokenFileCommand(); + const installCommand = () => + buildActionRunnerInstallCommand({ + pulseUrl: operations.selectedAgentUrl(), + agentId: target.actionRunnerAgentId!, + hostname: target.actionRunnerHostname!, + insecure: operations.insecureMode(), + customCaPath: operations.customCaPath(), + }); + return ( +
+
Save this credential now
+

+ This secret is held only in this page's memory and cannot be + recovered after you clear or leave it. It is never placed in a + URL, diagnostic report, installer command, or process + argument. +

+
+
+ 1. Start a private token prompt +
+
+ void copyCommand(tokenFileCommand())} + label="Copy private token-file command" + /> +
+                                          {tokenFileCommand()}
+                                        
+
+
+
+
+ 2. Copy this credential and paste it at the prompt +
+
+ void copyCommand(reveal().token)} + title="Copy one-time action-runner credential" + label={`Copy action-runner credential for ${target.displayName}`} + /> +
+                                          {reveal().token}
+                                        
+
+
+
+
+ 3. Install the separate runner +
+
+ void copyCommand(installCommand())} + label="Copy action-runner installer command" + /> +
+                                          {installCommand()}
+                                        
+
+
+ +
+ ); + }} +
+ 0} fallback={ diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureAgentDoctorPage.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureAgentDoctorPage.test.tsx new file mode 100644 index 000000000..538b024a9 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureAgentDoctorPage.test.tsx @@ -0,0 +1,156 @@ +import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { InfrastructureAgentDoctorPage } from '../InfrastructureAgentDoctorPage'; +import type { InfrastructureAgentDoctorTarget } from '../infrastructureAgentUpdateCommandsModel'; + +const issueCredential = vi.hoisted(() => vi.fn()); + +vi.mock('@/api/agentDiagnostics', async () => { + const actual = + await vi.importActual('@/api/agentDiagnostics'); + return { + ...actual, + AgentDiagnosticsAPI: { + ...actual.AgentDiagnosticsAPI, + issueActionRunnerCredential: issueCredential, + }, + }; +}); + +vi.mock('../useInfrastructureOperationsState', () => ({ + useInfrastructureOperationsContext: () => ({ + getAgentConnectionUpgradeCommandRequiresToken: () => false, + commandsUnlocked: () => true, + selectedAgentUrl: () => 'https://pulse.example.test', + insecureMode: () => false, + customCaPath: () => '/etc/pulse/ca.pem', + }), +})); + +const targetFixture = ( + overrides: Partial = {}, +): InfrastructureAgentDoctorTarget => ({ + key: 'agent:host-1', + connectionId: 'agent:host-1', + displayName: 'host-1', + contextLabel: 'Machine', + currentVersion: '6.3.0', + installFlags: [], + status: 'healthy', + reasons: [], + evidence: [], + needsUpdate: false, + needsCredentialRepair: false, + commandPlatform: 'linux', + privilegeLabel: + 'least privilege (pulse-agent) · commands monitoring-only · monitoring credential · typed helper configured', + safeCollector: true, + actionRunnerPosture: ['Credential not issued', 'Runner not connected'], + actionRunnerCredentialEligible: true, + actionRunnerCredentialAction: 'issue', + actionRunnerAgentId: 'host-1', + actionRunnerHostname: 'host-1.local', + source: 'diagnostics', + ...overrides, +}); + +describe('InfrastructureAgentDoctorPage action-runner enrollment', () => { + beforeEach(() => { + vi.clearAllMocks(); + issueCredential.mockResolvedValue({ + token: 'one-time-runner-secret', + tokenId: 'token-1', + organizationId: 'org-1', + agentId: 'host-1', + hostname: 'HOST-1.LOCAL.', + runtimeRole: 'action-runner', + actionCapability: 'typed_actions.v1', + }); + }); + + it('reveals the host-bound secret once and keeps it out of both commands', async () => { + render(() => ); + + expect(screen.getByText('Safe collector confirmed')).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { name: 'Issue one-time action-runner credential' }), + ); + + await waitFor(() => expect(screen.getByText('one-time-runner-secret')).toBeInTheDocument()); + expect(issueCredential).toHaveBeenCalledWith({ + agentId: 'host-1', + hostname: 'host-1.local', + name: 'host-1 action runner', + }); + const promptCommand = screen + .getByRole('button', { name: 'Copy private token-file command' }) + .parentElement?.querySelector('code')?.textContent; + const installCommand = screen + .getByRole('button', { name: 'Copy action-runner installer command' }) + .parentElement?.querySelector('code')?.textContent; + expect(promptCommand).toContain('read -rsp'); + expect(promptCommand).toContain('Action runner token: '); + expect(promptCommand).toContain('chmod 0600'); + expect(installCommand).toContain('--preflight-only'); + expect(installCommand).toContain('--update'); + expect(installCommand).toContain('--non-interactive'); + expect(installCommand).toContain('--action-token-file'); + expect(installCommand).toContain("--cacert '/etc/pulse/ca.pem'"); + expect(promptCommand).not.toContain('one-time-runner-secret'); + expect(installCommand).not.toContain('one-time-runner-secret'); + + fireEvent.click(screen.getByRole('button', { name: 'Clear credential from this page' })); + expect(screen.queryByText('one-time-runner-secret')).not.toBeInTheDocument(); + }); + + it('does not offer issuance when the reported posture is ineligible', () => { + render(() => ( + + )); + + expect( + screen.queryByRole('button', { name: 'Issue one-time action-runner credential' }), + ).not.toBeInTheDocument(); + expect(screen.getByText('Safe collector profile is not confirmed.')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Copy safe-profile inspection command' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Copy safe-profile apply command' }), + ).toBeInTheDocument(); + const commands = screen.getAllByText(/--preflight-only/).map((node) => node.textContent ?? ''); + expect(commands).toEqual( + expect.arrayContaining([ + expect.stringContaining('--safe-profile-inspect'), + expect.stringContaining('--safe-profile-apply'), + ]), + ); + expect(commands.every((command) => !command.includes('--update'))).toBe(true); + }); + + it('offers explicit rotation for an issued credential when the runner is disconnected', () => { + render(() => ( + + )); + + expect( + screen.getByRole('button', { name: 'Rotate action-runner credential' }), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts index da6538477..9c2870376 100644 --- a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts @@ -3,6 +3,10 @@ import type { AgentFleetAgentDiagnostic } from '@/api/agentDiagnostics'; import type { Connection } from '@/api/connections'; import type { InfrastructureSystemRow } from '../connectionsTableModel'; import { + buildActionRunnerInstallCommand, + buildActionRunnerTokenFileCommand, + buildSafeCollectorApplyCommand, + buildSafeCollectorInspectCommand, collectInfrastructureAgentDoctorTargets, diagnosticConnectionID, formatInfrastructureAgentDoctorReport, @@ -183,6 +187,182 @@ describe('Agent Doctor model', () => { expect(target.privilegeLabel).toBe('root · commands monitoring-only · credential grants exec'); }); + it('classifies a safe collector only from the complete reported boundary', () => { + const connection = connectionFixture(); + const safePrivilege = { + runningAsRoot: false, + serviceUser: 'pulse-agent', + commandAuthority: 'monitoring-only', + credentialKnown: true, + credentialExec: false, + typedHelper: true, + }; + const [safe] = collectInfrastructureAgentDoctorTargets({ + rows: [rowFixture(connection)], + connections: [connection], + diagnostics: [diagnosticFixture({ privilege: safePrivilege })], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }); + expect(safe).toMatchObject({ + safeCollector: true, + actionRunnerCredentialEligible: true, + actionRunnerAgentId: 'host-1', + actionRunnerHostname: 'host-1', + }); + expect(safe.privilegeLabel).toContain('typed helper configured'); + + for (const privilege of [ + { ...safePrivilege, runningAsRoot: true }, + { ...safePrivilege, commandAuthority: 'legacy' }, + { ...safePrivilege, credentialExec: true }, + { ...safePrivilege, typedHelper: false }, + ]) { + const [notSafe] = collectInfrastructureAgentDoctorTargets({ + rows: [rowFixture(connection)], + connections: [connection], + diagnostics: [diagnosticFixture({ privilege })], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }); + expect(notSafe.safeCollector).toBe(false); + expect(notSafe.actionRunnerCredentialEligible).toBe(false); + expect(notSafe.safeProfileGuidance).toContain('not confirmed'); + } + }); + + it('presents runner credential, connection, authority, and protocol facts', () => { + const connection = connectionFixture(); + const [target] = collectInfrastructureAgentDoctorTargets({ + rows: [rowFixture(connection)], + connections: [connection], + diagnostics: [ + diagnosticFixture({ + privilege: { + runningAsRoot: false, + serviceUser: 'pulse-agent', + commandAuthority: 'monitoring-only', + credentialKnown: true, + typedHelper: true, + actionRunnerCredentialIssued: true, + actionRunnerCredentialActive: true, + actionRunnerConnected: true, + actionRunnerVersion: '6.3.0', + actionRunnerRuntimeRole: 'action-runner', + actionRunnerCapability: 'typed-v1', + actionRunnerBindingVersion: 'host-v1', + actionRunnerReceiptProtocol: 1, + actionRunnerPreflightProtocol: 2, + actionRunnerDockerObservationProtocol: 3, + }, + }), + ], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }); + + expect(target.actionRunnerCredentialEligible).toBe(false); + expect(target.actionRunnerPosture).toEqual( + expect.arrayContaining([ + 'Credential issued (active)', + 'Runner connected · 6.3.0', + 'Authority action-runner · typed-v1 · host-v1', + 'Protocols receipt v1 · preflight v2 · Docker observation v3', + ]), + ); + + const [disconnected] = collectInfrastructureAgentDoctorTargets({ + rows: [rowFixture(connection)], + connections: [connection], + diagnostics: [ + diagnosticFixture({ + privilege: { + runningAsRoot: false, + serviceUser: 'pulse-agent', + commandAuthority: 'monitoring-only', + credentialKnown: true, + typedHelper: true, + actionRunnerCredentialIssued: true, + actionRunnerCredentialActive: true, + actionRunnerConnected: false, + }, + }), + ], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }); + expect(disconnected.actionRunnerCredentialEligible).toBe(true); + expect(disconnected.actionRunnerCredentialAction).toBe('rotate'); + }); + + it('never offers runner enrollment for a removed diagnostic', () => { + const [removed] = collectInfrastructureAgentDoctorTargets({ + rows: [], + connections: [], + diagnostics: [ + diagnosticFixture({ + status: 'removed', + privilege: { + runningAsRoot: false, + serviceUser: 'pulse-agent', + commandAuthority: 'monitoring-only', + credentialKnown: true, + typedHelper: true, + }, + }), + ], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }); + + expect(removed.status).toBe('removed'); + expect(removed.actionRunnerCredentialEligible).toBe(false); + expect(removed.actionRunnerCredentialBlockReason).toContain('Removed agents'); + }); + + it('builds a secret-free prompt-to-file handoff and explicit safe-profile migration commands', () => { + const secret = 'must-not-appear'; + const prompt = buildActionRunnerTokenFileCommand(); + expect(prompt).toContain('sudo bash -c'); + expect(prompt).toContain('read -rsp'); + expect(prompt).toContain('Action runner token: '); + expect(prompt).toContain(' { const connection = connectionFixture({ agentUpdateAvailable: false, @@ -779,6 +959,10 @@ describe('Agent Doctor model', () => { needsUpdate: false, needsCredentialRepair: false, commandPlatform: null, + safeCollector: false, + actionRunnerPosture: [], + actionRunnerCredentialEligible: false, + actionRunnerCredentialAction: 'issue', source: 'diagnostics', ...overrides, }); diff --git a/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts b/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts index 6ec04365d..b64057bfe 100644 --- a/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts +++ b/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts @@ -5,8 +5,12 @@ import { formatAgentVersionDisplay, parseAgentVersion, } from '@/utils/agentVersion'; -import type { AgentCommandPlatform } from '@/utils/agentInstallCommand'; +import { + buildUnixAgentInstallCommand, + type AgentCommandPlatform, +} from '@/utils/agentInstallCommand'; import type { InfrastructureSystemRow } from './connectionsTableModel'; +import { shellQuoteArg } from './infrastructureOperationsModel'; export type InfrastructureAgentUpdateTarget = { key: string; @@ -39,6 +43,14 @@ export type InfrastructureAgentDoctorTarget = Omit< profileLabel?: string; profileVersionLabel?: string; privilegeLabel?: string; + safeCollector: boolean; + safeProfileGuidance?: string; + actionRunnerPosture: string[]; + actionRunnerCredentialEligible: boolean; + actionRunnerCredentialAction: 'issue' | 'rotate'; + actionRunnerCredentialBlockReason?: string; + actionRunnerAgentId?: string; + actionRunnerHostname?: string; lastSeen?: number | string | null; source: 'diagnostics' | 'ledger-fallback' | 'removed'; }; @@ -603,6 +615,7 @@ const doctorTargetFromBinding = ( const profileLabel = diagnostic?.profileName?.trim() || diagnostic?.profileId?.trim() || undefined; const profileVersionLabel = diagnosticProfileVersionLabel(diagnostic); + const actionRunner = actionRunnerPresentation(diagnostic, commandPlatform, connection); return { key: connection.id, @@ -625,6 +638,7 @@ const doctorTargetFromBinding = ( profileLabel, profileVersionLabel, privilegeLabel: diagnosticPrivilegeLabel(diagnostic), + ...actionRunner, lastSeen: connection.lastSeen ?? diagnostic?.lastSeen, source: diagnosticsAvailable && diagnostic ? 'diagnostics' : 'ledger-fallback', }; @@ -685,15 +699,170 @@ const diagnosticPrivilegeLabel = (diagnostic?: AgentFleetAgentDiagnostic): strin if (privilege.credentialKnown) { helpers.push(privilege.credentialExec ? 'credential grants exec' : 'monitoring credential'); } + if (privilege.typedHelper) helpers.push('typed helper configured'); if (privilege.smartctlHelper) helpers.push('SMART helper active'); if (privilege.pctHelper) helpers.push('pct helper active'); return helpers.length > 0 ? `${base} · ${helpers.join(' · ')}` : base; }; +const isSafeCollectorPrivilege = (diagnostic?: AgentFleetAgentDiagnostic): boolean => { + const privilege = diagnostic?.privilege; + return Boolean( + privilege && + !privilege.runningAsRoot && + privilege.commandAuthority?.trim() === 'monitoring-only' && + privilege.credentialKnown && + !privilege.credentialExec && + privilege.typedHelper, + ); +}; + +const actionRunnerPresentation = ( + diagnostic: AgentFleetAgentDiagnostic | undefined, + commandPlatform: AgentCommandPlatform | null, + connection?: Connection, + removed = false, +) => { + const privilege = diagnostic?.privilege; + const safeCollector = isSafeCollectorPrivilege(diagnostic); + const agentId = + diagnostic?.agentId?.trim() || normalizeAgentConnectionID(connection?.id).slice(6); + const hostname = + diagnostic?.hostname?.trim() || connection?.agentIdentity?.hostname?.trim() || undefined; + const systemdLooking = + commandPlatform === 'linux' && Boolean(privilege?.serviceUser?.trim()) && Boolean(hostname); + const posture: string[] = []; + if (privilege) { + posture.push( + privilege.actionRunnerCredentialIssued + ? `Credential issued (${privilege.actionRunnerCredentialActive ? 'active' : 'inactive'})` + : 'Credential not issued', + ); + posture.push( + privilege.actionRunnerConnected + ? `Runner connected${privilege.actionRunnerVersion?.trim() ? ` · ${privilege.actionRunnerVersion.trim()}` : ''}` + : 'Runner not connected', + ); + const authority = [ + privilege.actionRunnerRuntimeRole?.trim(), + privilege.actionRunnerCapability?.trim(), + privilege.actionRunnerBindingVersion?.trim(), + ].filter(Boolean); + if (authority.length > 0) posture.push(`Authority ${authority.join(' · ')}`); + const protocols = [ + privilege.actionRunnerReceiptProtocol + ? `receipt v${privilege.actionRunnerReceiptProtocol}` + : '', + privilege.actionRunnerPreflightProtocol + ? `preflight v${privilege.actionRunnerPreflightProtocol}` + : '', + privilege.actionRunnerDockerObservationProtocol + ? `Docker observation v${privilege.actionRunnerDockerObservationProtocol}` + : '', + ].filter(Boolean); + if (protocols.length > 0) posture.push(`Protocols ${protocols.join(' · ')}`); + if (privilege.actionRunnerConnectedAt) { + posture.push(`Connected at ${new Date(privilege.actionRunnerConnectedAt).toLocaleString()}`); + } + } + + let safeProfileGuidance: string | undefined; + if (!safeCollector) { + safeProfileGuidance = + commandPlatform === 'linux' + ? 'Safe collector profile is not confirmed. Inspect the host service and apply --least-privilege --enable-privileged-helper only on a reviewed standard Linux systemd host.' + : 'The safe collector profile is supported only on reviewed standard Linux systemd hosts. Inspect this installation. Pulse will not default or guess a migration for this platform.'; + } + + let blockReason: string | undefined; + if (removed) blockReason = 'Removed agents cannot enroll an action runner.'; + else if (!safeCollector) blockReason = 'Confirm the safe collector profile first.'; + else if (!systemdLooking) + blockReason = 'The diagnostic does not prove a standard Linux systemd installation.'; + else if (privilege?.actionRunnerConnected) + blockReason = 'The action runner is already connected.'; + else if (!agentId || !hostname) blockReason = 'Canonical host identity is incomplete.'; + + return { + safeCollector, + safeProfileGuidance, + actionRunnerPosture: posture, + actionRunnerCredentialEligible: !blockReason, + actionRunnerCredentialAction: privilege?.actionRunnerCredentialIssued + ? ('rotate' as const) + : ('issue' as const), + actionRunnerCredentialBlockReason: blockReason, + actionRunnerAgentId: agentId || undefined, + actionRunnerHostname: hostname, + }; +}; + +const ACTION_RUNNER_TOKEN_FILE = '/etc/pulse-agent-runner/token'; + +export const buildActionRunnerTokenFileCommand = (): string => + `sudo bash -c ${shellQuoteArg( + [ + 'set -e', + `install -d -m 0700 ${shellQuoteArg(ACTION_RUNNER_TOKEN_FILE.slice(0, ACTION_RUNNER_TOKEN_FILE.lastIndexOf('/')))}`, + 'umask 077', + `IFS= read -rsp 'Action runner token: ' token /dev/tty`, + `printf '%s\\n' "$token" > ${shellQuoteArg(ACTION_RUNNER_TOKEN_FILE)}`, + `chmod 0600 ${shellQuoteArg(ACTION_RUNNER_TOKEN_FILE)}`, + 'unset token', + ].join('\n'), + )}`; + +export const buildActionRunnerInstallCommand = (options: { + pulseUrl: string; + agentId: string; + hostname: string; + insecure?: boolean; + customCaPath?: string; +}): string => + buildUnixAgentInstallCommand({ + baseUrl: options.pulseUrl, + insecure: options.insecure, + caCertPath: options.customCaPath, + extraArgs: [ + '--update', + '--least-privilege', + '--enable-privileged-helper', + '--enable-action-runner', + `--action-token-file ${shellQuoteArg(ACTION_RUNNER_TOKEN_FILE)}`, + `--agent-id ${shellQuoteArg(options.agentId)}`, + `--hostname ${shellQuoteArg(options.hostname)}`, + ], + }); + +type SafeCollectorCommandOptions = { + pulseUrl: string; + insecure?: boolean; + customCaPath?: string; +}; + +export const buildSafeCollectorInspectCommand = (options: SafeCollectorCommandOptions): string => + buildUnixAgentInstallCommand({ + baseUrl: options.pulseUrl, + insecure: options.insecure, + caCertPath: options.customCaPath, + extraArgs: ['--safe-profile-inspect'], + }); + +export const buildSafeCollectorApplyCommand = (options: SafeCollectorCommandOptions): string => + buildUnixAgentInstallCommand({ + baseUrl: options.pulseUrl, + insecure: options.insecure, + caCertPath: options.customCaPath, + extraArgs: ['--safe-profile-apply'], + }); + const diagnosticOnlyDoctorTarget = ( diagnostic: AgentFleetAgentDiagnostic, ): InfrastructureAgentDoctorTarget => { const removed = diagnostic.status === 'removed'; + const commandPlatform = resolveKnownAgentCommandPlatform(diagnostic.platform); + const actionRunner = actionRunnerPresentation(diagnostic, commandPlatform, undefined, removed); return { key: `${removed ? 'removed' : 'diagnostic'}:${diagnostic.rowKey || diagnostic.id}`, connectionId: diagnosticConnectionID(diagnostic), @@ -707,10 +876,11 @@ const diagnosticOnlyDoctorTarget = ( evidence: evidenceFor(undefined, diagnostic), needsUpdate: false, needsCredentialRepair: false, - commandPlatform: resolveKnownAgentCommandPlatform(diagnostic.platform), + commandPlatform, profileLabel: diagnostic.profileName?.trim() || diagnostic.profileId?.trim() || undefined, profileVersionLabel: diagnosticProfileVersionLabel(diagnostic), privilegeLabel: diagnosticPrivilegeLabel(diagnostic), + ...actionRunner, lastSeen: diagnostic.lastSeen, source: removed ? 'removed' : 'diagnostics', }; @@ -872,6 +1042,8 @@ export const formatInfrastructureAgentDoctorReport = ( if (lastSeen) lines.push(` Last seen ${lastSeen}`); if (target.updaterLabel) lines.push(` Updater ${target.updaterLabel}`); if (target.privilegeLabel) lines.push(` Privilege ${target.privilegeLabel}`); + lines.push(` Safe collector ${target.safeCollector ? 'confirmed' : 'not confirmed'}`); + for (const fact of target.actionRunnerPosture ?? []) lines.push(` Action runner ${fact}`); if (target.profileLabel) { lines.push( ` Profile ${target.profileLabel}${ diff --git a/internal/api/action_runner_credentials_test.go b/internal/api/action_runner_credentials_test.go index 437349202..2c96c2d11 100644 --- a/internal/api/action_runner_credentials_test.go +++ b/internal/api/action_runner_credentials_test.go @@ -80,6 +80,32 @@ func TestIssueActionRunnerCredentialRejectsUnknownOrMismatchedHost(t *testing.T) } } +func TestIssueActionRunnerCredentialRouteRotatesExistingHostBinding(t *testing.T) { + router, cfg, hostID := newActionRunnerCredentialTestRouter(t) + issue := func() actionRunnerCredentialResponse { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/agents/action-runner/credential", actionRunnerCredentialBody(hostID, "HOST-1")) + rec := httptest.NewRecorder() + router.handleIssueActionRunnerCredential(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + var response actionRunnerCredentialResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + return response + } + first := issue() + second := issue() + if first.TokenID == second.TokenID || first.Token == second.Token { + t.Fatalf("re-enrollment did not rotate credential: first=%#v second=%#v", first, second) + } + if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != second.TokenID { + t.Fatalf("route accumulated host-bound credentials: %#v", cfg.APITokens) + } +} + func TestActionRunnerCredentialRouteAuthSupportsAdminSessionAndScopedToken(t *testing.T) { for _, mode := range []string{"api-token", "admin-session"} { t.Run(mode, func(t *testing.T) { diff --git a/internal/api/agent_fleet_doctor.go b/internal/api/agent_fleet_doctor.go index 02b69e9a4..984b70660 100644 --- a/internal/api/agent_fleet_doctor.go +++ b/internal/api/agent_fleet_doctor.go @@ -3,8 +3,13 @@ package api import ( "encoding/json" "net/http" + "strings" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/updates" "github.com/rs/zerolog/log" ) @@ -26,9 +31,113 @@ func (r *Router) handleAgentFleetDiagnostics(w http.ResponseWriter, req *http.Re serverVersion = versionInfo.Version } agentUpdateTargetVersion := currentAgentTargetVersion() + now := time.Now().UTC() + diagnostics := monitor.GetAgentFleetDiagnosticsForTarget(serverVersion, agentUpdateTargetVersion, now) + organizationID := GetOrgID(req.Context()) + connected := []agentexec.ConnectedAgent(nil) + if r.agentExecServer != nil { + connected = r.agentExecServer.GetConnectedAgentsForOrganization(organizationID) + } + applyAgentFleetActionRunnerState(&diagnostics, actionRunnerCredentialSnapshot(r.config), connected, organizationID, now) w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(monitor.GetAgentFleetDiagnosticsForTarget(serverVersion, agentUpdateTargetVersion, time.Now().UTC())); err != nil { + if err := json.NewEncoder(w).Encode(diagnostics); err != nil { log.Error().Err(err).Msg("Failed to serialize agent fleet diagnostics") } } + +// applyAgentFleetActionRunnerState joins the existing organization-scoped +// command-session inventory to the existing Agent Doctor response. Admission +// has already bound an action runner to one canonical host identity; the +// projection still filters the closed runtime role and capability before +// reporting it as connected. +func actionRunnerCredentialSnapshot(cfg *config.Config) []config.APITokenRecord { + if cfg == nil { + return nil + } + config.Mu.RLock() + defer config.Mu.RUnlock() + records := make([]config.APITokenRecord, len(cfg.APITokens)) + for i := range cfg.APITokens { + records[i] = cfg.APITokens[i].Clone() + } + return records +} + +func applyAgentFleetActionRunnerState( + diagnostics *monitoring.AgentFleetDiagnostics, + records []config.APITokenRecord, + connected []agentexec.ConnectedAgent, + organizationID string, + now time.Time, +) { + if diagnostics == nil || len(diagnostics.Agents) == 0 { + return + } + organizationID = strings.TrimSpace(organizationID) + if organizationID == "" { + organizationID = "default" + } + issued := make(map[string]config.APITokenRecord, len(records)) + for _, record := range records { + if strings.TrimSpace(record.OrgID) != organizationID || + strings.TrimSpace(record.Metadata[agenttokens.CredentialKindMetadataKey]) != agenttokens.CredentialKindActionRunner { + continue + } + agentID := strings.TrimSpace(record.Metadata["bound_agent_id"]) + if agentID == "" { + continue + } + previous, found := issued[agentID] + if !found || (!actionRunnerCredentialActive(previous, now) && actionRunnerCredentialActive(record, now)) { + issued[agentID] = record + } + } + runners := make(map[string]agentexec.ConnectedAgent, len(connected)) + for _, candidate := range connected { + agentID := strings.TrimSpace(candidate.AgentID) + if agentID == "" || strings.TrimSpace(candidate.RuntimeRole) != agentexec.RuntimeRoleActionRunner || + strings.TrimSpace(candidate.ActionCapability) != agentexec.ActionCapabilityTypedV1 { + continue + } + runners[agentID] = candidate + } + for i := range diagnostics.Agents { + agentID := strings.TrimSpace(diagnostics.Agents[i].AgentID) + record, credentialIssued := issued[agentID] + runner, runnerConnected := runners[agentID] + if !credentialIssued && !runnerConnected { + continue + } + if diagnostics.Agents[i].Privilege == nil { + diagnostics.Agents[i].Privilege = &monitoring.AgentFleetDiagnosticPrivilege{} + } + privilege := diagnostics.Agents[i].Privilege + if credentialIssued { + privilege.ActionRunnerCredentialIssued = true + privilege.ActionRunnerRuntimeRole = strings.TrimSpace(record.Metadata[agenttokens.CredentialKindMetadataKey]) + privilege.ActionRunnerCapability = strings.TrimSpace(record.Metadata[agenttokens.ActionCapabilityMetadataKey]) + privilege.ActionRunnerBindingVersion = strings.TrimSpace(record.Metadata[agenttokens.ActionBindingVersionMetadataKey]) + privilege.ActionRunnerCredentialActive = actionRunnerCredentialActive(record, now) + } + if runnerConnected { + privilege.ActionRunnerConnected = true + privilege.ActionRunnerRuntimeRole = strings.TrimSpace(runner.RuntimeRole) + privilege.ActionRunnerCapability = strings.TrimSpace(runner.ActionCapability) + privilege.ActionRunnerVersion = strings.TrimSpace(runner.Version) + privilege.ActionRunnerReceiptProtocol = runner.OperationReceiptVersion + privilege.ActionRunnerPreflightProtocol = runner.ActionPreflightVersion + privilege.ActionRunnerDockerObservationProtocol = runner.DockerObservationVersion + if !runner.ConnectedAt.IsZero() { + privilege.ActionRunnerConnectedAt = runner.ConnectedAt.UTC().UnixMilli() + } + } + } +} + +func actionRunnerCredentialActive(record config.APITokenRecord, now time.Time) bool { + return record.HasScope(config.ScopeAgentExec) && + strings.TrimSpace(record.Metadata[agenttokens.ActionCapabilityMetadataKey]) == agenttokens.ActionCapabilityTypedV1 && + strings.TrimSpace(record.Metadata[agenttokens.ActionBindingVersionMetadataKey]) == agenttokens.ActionBindingVersion && + (record.ExpiresAt == nil || record.ExpiresAt.After(now)) +} diff --git a/internal/api/agent_fleet_doctor_test.go b/internal/api/agent_fleet_doctor_test.go index b3a930934..25c9f00a6 100644 --- a/internal/api/agent_fleet_doctor_test.go +++ b/internal/api/agent_fleet_doctor_test.go @@ -7,6 +7,8 @@ import ( "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" @@ -59,3 +61,58 @@ func TestHandleAgentFleetDiagnosticsReturnsFleetPayload(t *testing.T) { t.Fatalf("agent update target = %q, want %q", payload.AgentUpdateTargetVersion, currentAgentTargetVersion()) } } + +func TestApplyAgentFleetActionRunnerStateMatchesOnlyBoundTypedRunner(t *testing.T) { + connectedAt := time.Date(2026, 8, 29, 16, 30, 0, 0, time.UTC) + expiredAt := connectedAt.Add(-time.Minute) + diagnostics := monitoring.AgentFleetDiagnostics{Agents: []monitoring.AgentFleetAgentDiagnostic{ + { + AgentID: "host-a", + Hostname: "host-a.example", + Privilege: &monitoring.AgentFleetDiagnosticPrivilege{ + CommandAuthority: "monitoring-only", + TypedHelper: true, + }, + }, + {AgentID: "host-b", Hostname: "host-b.example"}, + }} + applyAgentFleetActionRunnerState(&diagnostics, []config.APITokenRecord{ + { + OrgID: "default", Scopes: []string{config.ScopeAgentExec}, + Metadata: map[string]string{ + agenttokens.CredentialKindMetadataKey: agenttokens.CredentialKindActionRunner, + agenttokens.ActionCapabilityMetadataKey: agenttokens.ActionCapabilityTypedV1, + agenttokens.ActionBindingVersionMetadataKey: agenttokens.ActionBindingVersion, + "bound_agent_id": "host-a", + }, + }, + { + OrgID: "default", Scopes: []string{config.ScopeAgentExec}, ExpiresAt: &expiredAt, + Metadata: map[string]string{ + agenttokens.CredentialKindMetadataKey: agenttokens.CredentialKindActionRunner, + agenttokens.ActionCapabilityMetadataKey: agenttokens.ActionCapabilityTypedV1, + agenttokens.ActionBindingVersionMetadataKey: agenttokens.ActionBindingVersion, + "bound_agent_id": "host-b", + }, + }, + {OrgID: "other-org", Metadata: map[string]string{agenttokens.CredentialKindMetadataKey: agenttokens.CredentialKindActionRunner, "bound_agent_id": "host-a"}}, + }, []agentexec.ConnectedAgent{ + {AgentID: "host-a", RuntimeRole: agentexec.RuntimeRoleActionRunner, ActionCapability: agentexec.ActionCapabilityTypedV1, Version: "6.3.0-linux-amd64", ConnectedAt: connectedAt, OperationReceiptVersion: 1, ActionPreflightVersion: 2, DockerObservationVersion: 2}, + {AgentID: "host-b", RuntimeRole: agentexec.RuntimeRoleLegacyFullTrust, ActionCapability: agentexec.ActionCapabilityTypedV1, Version: "legacy"}, + {AgentID: "host-c", RuntimeRole: agentexec.RuntimeRoleActionRunner, ActionCapability: agentexec.ActionCapabilityTypedV1, Version: "other-host"}, + }, "default", connectedAt) + + hostA := diagnostics.Agents[0].Privilege + if hostA == nil || hostA.CommandAuthority != "monitoring-only" || !hostA.TypedHelper || + !hostA.ActionRunnerCredentialIssued || !hostA.ActionRunnerCredentialActive || !hostA.ActionRunnerConnected || + hostA.ActionRunnerRuntimeRole != agentexec.RuntimeRoleActionRunner || hostA.ActionRunnerCapability != agentexec.ActionCapabilityTypedV1 || + hostA.ActionRunnerBindingVersion != agenttokens.ActionBindingVersion || hostA.ActionRunnerVersion != "6.3.0-linux-amd64" || + hostA.ActionRunnerConnectedAt != connectedAt.UnixMilli() || hostA.ActionRunnerReceiptProtocol != 1 || + hostA.ActionRunnerPreflightProtocol != 2 || hostA.ActionRunnerDockerObservationProtocol != 2 { + t.Fatalf("host-a runtime posture = %+v", hostA) + } + hostB := diagnostics.Agents[1].Privilege + if hostB == nil || !hostB.ActionRunnerCredentialIssued || hostB.ActionRunnerCredentialActive || hostB.ActionRunnerConnected { + t.Fatalf("host-b credential/session posture = %+v", hostB) + } +} diff --git a/internal/api/agenttokens/install.go b/internal/api/agenttokens/install.go index 57b40f7f5..11d6f877f 100644 --- a/internal/api/agenttokens/install.go +++ b/internal/api/agenttokens/install.go @@ -109,6 +109,15 @@ func ParseCommandPolicyIntent(record *config.APITokenRecord) (bool, bool) { } func IssueAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, opts IssueOptions) (string, *config.APITokenRecord, error) { + return issueAndPersistReplacing(cfg, persistence, opts, nil) +} + +func issueAndPersistReplacing( + cfg *config.Config, + persistence *config.ConfigPersistence, + opts IssueOptions, + replace func(config.APITokenRecord) bool, +) (string, *config.APITokenRecord, error) { if cfg == nil { return "", nil, fmt.Errorf("config is required") } @@ -147,7 +156,17 @@ func IssueAndPersist(cfg *config.Config, persistence *config.ConfigPersistence, defer config.Mu.Unlock() previousTokens := append([]config.APITokenRecord(nil), cfg.APITokens...) - cfg.APITokens = append(cfg.APITokens, *record) + if replace == nil { + cfg.APITokens = append(cfg.APITokens, *record) + } else { + nextTokens := make([]config.APITokenRecord, 0, len(cfg.APITokens)+1) + for _, existing := range cfg.APITokens { + if !replace(existing) { + nextTokens = append(nextTokens, existing) + } + } + cfg.APITokens = append(nextTokens, *record) + } cfg.SortAPITokens() if persistence != nil { if err := persistence.SaveAPITokens(cfg.APITokens); err != nil { @@ -187,7 +206,7 @@ func IssueActionRunnerAndPersist(cfg *config.Config, persistence *config.ConfigP if tokenName == "" { tokenName = "action-runner:" + hostname } - return IssueAndPersist(cfg, persistence, IssueOptions{ + return issueAndPersistReplacing(cfg, persistence, IssueOptions{ TokenName: tokenName, OrgID: organizationID, OwnerUserID: opts.OwnerUserID, @@ -200,6 +219,10 @@ func IssueActionRunnerAndPersist(cfg *config.Config, persistence *config.ConfigP "bound_hostname": hostname, "bound_at": time.Now().UTC().Format(time.RFC3339), }, + }, func(record config.APITokenRecord) bool { + return strings.TrimSpace(record.OrgID) == organizationID && + strings.TrimSpace(record.Metadata[CredentialKindMetadataKey]) == CredentialKindActionRunner && + strings.TrimSpace(record.Metadata["bound_agent_id"]) == agentID }) } diff --git a/internal/api/agenttokens/install_test.go b/internal/api/agenttokens/install_test.go index 3a121863c..4cf012115 100644 --- a/internal/api/agenttokens/install_test.go +++ b/internal/api/agenttokens/install_test.go @@ -74,6 +74,88 @@ func TestIssueActionRunnerAndPersistRejectsIncompleteBinding(t *testing.T) { } } +func TestIssueActionRunnerAndPersistReplacesMatchingBoundCredential(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + _, first, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{ + OrgID: "org-a", AgentID: "machine-123", Hostname: "Node.EXAMPLE", + }) + if err != nil { + t.Fatalf("first IssueActionRunnerAndPersist: %v", err) + } + _, otherHost, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{ + OrgID: "org-a", AgentID: "machine-456", Hostname: "other.example", + }) + if err != nil { + t.Fatalf("other-host IssueActionRunnerAndPersist: %v", err) + } + _, replacement, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{ + // Canonical agent identity survives a host rename. The old binding can no + // longer admit that host and must not remain as an orphaned secret. + OrgID: "org-a", AgentID: "machine-123", Hostname: "renamed.example", + }) + if err != nil { + t.Fatalf("replacement IssueActionRunnerAndPersist: %v", err) + } + if replacement.ID == first.ID { + t.Fatalf("replacement reused token id %q", replacement.ID) + } + if len(cfg.APITokens) != 2 { + t.Fatalf("token inventory = %#v, want one replacement plus other host", cfg.APITokens) + } + foundReplacement, foundOther, foundFirst := false, false, false + for _, record := range cfg.APITokens { + foundReplacement = foundReplacement || record.ID == replacement.ID + foundOther = foundOther || record.ID == otherHost.ID + foundFirst = foundFirst || record.ID == first.ID + } + if !foundReplacement || !foundOther || foundFirst { + t.Fatalf("replacement inventory = %#v", cfg.APITokens) + } +} + +func TestIssueActionRunnerAndPersistRestoresReplacedCredentialOnPersistenceFailure(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + _, prior, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{ + OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example", + }) + if err != nil { + t.Fatalf("seed action credential: %v", err) + } + _, unrelated, err := IssueAndPersist(cfg, nil, IssueOptions{TokenName: "collector", OrgID: "org-a"}) + if err != nil { + t.Fatalf("seed unrelated credential: %v", err) + } + + statePath := filepath.Join(t.TempDir(), "blocked-state") + persistence := config.NewConfigPersistence(statePath) + if err := os.RemoveAll(statePath); err != nil { + t.Fatalf("remove persistence directory: %v", err) + } + if err := os.WriteFile(statePath, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("create persistence blocker: %v", err) + } + raw, replacement, err := IssueActionRunnerAndPersist(cfg, persistence, ActionRunnerIssueOptions{ + OrgID: "org-a", AgentID: "machine-123", Hostname: "node.example", + }) + if !errors.Is(err, ErrPersist) { + t.Fatalf("replacement error = %v, want ErrPersist", err) + } + if raw != "" || replacement != nil { + t.Fatalf("failed replacement returned credential: raw=%q record=%#v", raw, replacement) + } + if len(cfg.APITokens) != 2 { + t.Fatalf("restored inventory = %#v", cfg.APITokens) + } + foundPrior, foundUnrelated := false, false + for _, record := range cfg.APITokens { + foundPrior = foundPrior || record.ID == prior.ID + foundUnrelated = foundUnrelated || record.ID == unrelated.ID + } + if !foundPrior || !foundUnrelated { + t.Fatalf("full prior inventory was not restored: %#v", cfg.APITokens) + } +} + func TestIssueAndPersistRejectsMonitoringRoleWithExecAuthority(t *testing.T) { _, _, err := IssueAndPersist(&config.Config{}, nil, IssueOptions{ TokenName: "invalid", diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index aa2527ddd..f52888f04 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -20798,7 +20798,8 @@ func TestContract_AgentFleetDiagnosticsEndpointSurfacesStableShape(t *testing.T) !strings.Contains(routerSrc, `RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.handleAgentFleetDiagnostics))`) { t.Error("agent fleet diagnostics route must remain admin settings:read only") } - if !strings.Contains(handlerSrc, "GetAgentFleetDiagnosticsForTarget(serverVersion, agentUpdateTargetVersion, time.Now().UTC())") { + if !strings.Contains(handlerSrc, "GetAgentFleetDiagnosticsForTarget(serverVersion, agentUpdateTargetVersion, now)") || + !strings.Contains(handlerSrc, "applyAgentFleetActionRunnerState(&diagnostics") { t.Error("agent fleet diagnostics handler must delegate to the monitoring-owned read-only producer") } for _, required := range []struct { @@ -20818,6 +20819,15 @@ func TestContract_AgentFleetDiagnosticsEndpointSurfacesStableShape(t *testing.T) {reflect.TypeOf(monitoring.AgentFleetAgentDiagnostic{}), "AgentModules", reflect.TypeOf([]monitoring.AgentFleetDiagnosticModule(nil)), "agentModules,omitempty"}, {reflect.TypeOf(monitoring.AgentFleetAgentDiagnostic{}), "Reasons", reflect.TypeOf([]monitoring.AgentFleetDiagnosticReason(nil)), "reasons"}, {reflect.TypeOf(monitoring.AgentFleetAgentDiagnostic{}), "RepairActions", reflect.TypeOf([]monitoring.AgentFleetDiagnosticRepair(nil)), "repairActions,omitempty"}, + {reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "TypedHelper", reflect.TypeOf(false), "typedHelper,omitempty"}, + {reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerCredentialIssued", reflect.TypeOf(false), "actionRunnerCredentialIssued,omitempty"}, + {reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerCredentialActive", reflect.TypeOf(false), "actionRunnerCredentialActive,omitempty"}, + {reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerRuntimeRole", reflect.TypeOf(""), "actionRunnerRuntimeRole,omitempty"}, + {reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerCapability", reflect.TypeOf(""), "actionRunnerCapability,omitempty"}, + {reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerConnected", reflect.TypeOf(false), "actionRunnerConnected,omitempty"}, + {reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerReceiptProtocol", reflect.TypeOf(0), "actionRunnerReceiptProtocol,omitempty"}, + {reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerPreflightProtocol", reflect.TypeOf(0), "actionRunnerPreflightProtocol,omitempty"}, + {reflect.TypeOf(monitoring.AgentFleetDiagnosticPrivilege{}), "ActionRunnerDockerObservationProtocol", reflect.TypeOf(0), "actionRunnerDockerObservationProtocol,omitempty"}, } { field, ok := required.typeOf.FieldByName(required.name) if !ok { diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 7980ff11f..a7e36b691 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -696,6 +696,36 @@ func TestActionRunnerCredentialAdmissionRejectsWrongIdentityAndUnboundOrganizati } } +func TestActionRunnerCredentialRotationRevokesPreviousSession(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + firstToken, firstRecord, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{ + OrgID: "org-a", AgentID: "machine-a", Hostname: "node.example", + }) + if err != nil { + t.Fatal(err) + } + router := &Router{config: cfg} + if _, ok := router.admitAgentExecToken(firstToken, "machine-a", "node.example"); !ok { + t.Fatal("initial action runner credential was rejected") + } + + secondToken, secondRecord, err := agenttokens.IssueActionRunnerAndPersist(cfg, nil, agenttokens.ActionRunnerIssueOptions{ + OrgID: "org-a", AgentID: "machine-a", Hostname: "renamed.example", + }) + if err != nil { + t.Fatal(err) + } + if firstRecord.ID == secondRecord.ID || firstToken == secondToken || len(cfg.APITokens) != 1 { + t.Fatalf("rotation did not replace the prior credential: %#v", cfg.APITokens) + } + if _, ok := router.admitAgentExecToken(firstToken, "machine-a", "node.example"); ok { + t.Fatal("replaced action runner credential remained admissible") + } + if _, ok := router.admitAgentExecToken(secondToken, "machine-a", "renamed.example"); !ok { + t.Fatal("replacement action runner credential was rejected") + } +} + func TestAgentExecTokenRejectsAmbiguousMultiOrganizationAuthority(t *testing.T) { rawToken := "multi-org-agent-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{ diff --git a/internal/hostagent/privilege.go b/internal/hostagent/privilege.go index f57d9dc75..14dafebcf 100644 --- a/internal/hostagent/privilege.go +++ b/internal/hostagent/privilege.go @@ -18,6 +18,7 @@ func collectPrivilegeStatus(commandAuthority CommandAuthorityProfile) *agentshos status := &agentshost.PrivilegeStatus{ RunningAsRoot: os.Geteuid() == 0, CommandAuthority: string(commandAuthority), + TypedHelper: strings.TrimSpace(os.Getenv("PULSE_AGENT_HELPER_SOCKET")) != "", SmartctlHelper: strings.TrimSpace(os.Getenv("PULSE_SMARTCTL_PATH")) != "", PctHelper: strings.TrimSpace(os.Getenv("PULSE_PCT_PATH")) != "", } diff --git a/internal/hostagent/privilege_test.go b/internal/hostagent/privilege_test.go index 23693290c..a16088962 100644 --- a/internal/hostagent/privilege_test.go +++ b/internal/hostagent/privilege_test.go @@ -7,6 +7,7 @@ import ( ) func TestCollectPrivilegeStatusReportsProcessFacts(t *testing.T) { + t.Setenv("PULSE_AGENT_HELPER_SOCKET", "") t.Setenv("PULSE_SMARTCTL_PATH", "") t.Setenv("PULSE_PCT_PATH", "") @@ -23,17 +24,18 @@ func TestCollectPrivilegeStatusReportsProcessFacts(t *testing.T) { if status.CommandAuthority != string(CommandAuthorityMonitoringOnly) { t.Fatalf("CommandAuthority = %q", status.CommandAuthority) } - if status.SmartctlHelper || status.PctHelper { + if status.TypedHelper || status.SmartctlHelper || status.PctHelper { t.Fatalf("helper flags set without overrides: %+v", status) } } func TestCollectPrivilegeStatusReportsHelperOverrides(t *testing.T) { + t.Setenv("PULSE_AGENT_HELPER_SOCKET", "/run/pulse-agent/helper.sock") 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(CommandAuthorityCommandCapable) - if !status.SmartctlHelper || !status.PctHelper { + if !status.TypedHelper || !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 968614e50..6b06ed083 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -431,6 +431,7 @@ type AgentPrivilegeStatus struct { RunningAsRoot bool `json:"runningAsRoot"` ServiceUser string `json:"serviceUser,omitempty"` CommandAuthority string `json:"commandAuthority,omitempty"` + TypedHelper bool `json:"typedHelper,omitempty"` SmartctlHelper bool `json:"smartctlHelper,omitempty"` PctHelper bool `json:"pctHelper,omitempty"` } diff --git a/internal/monitoring/agent_fleet_doctor.go b/internal/monitoring/agent_fleet_doctor.go index 778a3a187..f14d9898c 100644 --- a/internal/monitoring/agent_fleet_doctor.go +++ b/internal/monitoring/agent_fleet_doctor.go @@ -116,18 +116,31 @@ type AgentFleetDiagnosticModule struct { UpdatedAt time.Time `json:"updatedAt"` } -// AgentFleetDiagnosticPrivilege is the agent-reported privilege profile, -// surfaced descriptively beside modules and update state. It is deliberately -// not a Reason: a least-privilege install is an intentional hardening choice, -// so it must never degrade the agent's health status by itself. +// AgentFleetDiagnosticPrivilege combines the collector-reported privilege +// profile with server-observed action-runner connection state. It is surfaced +// descriptively beside modules and update state. It is deliberately not a +// Reason: a least-privilege install or a disconnected optional runner must +// never degrade monitoring health by itself. type AgentFleetDiagnosticPrivilege struct { - 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"` + RunningAsRoot bool `json:"runningAsRoot"` + ServiceUser string `json:"serviceUser,omitempty"` + CommandAuthority string `json:"commandAuthority,omitempty"` + CredentialKnown bool `json:"credentialKnown,omitempty"` + CredentialExec bool `json:"credentialExec,omitempty"` + TypedHelper bool `json:"typedHelper,omitempty"` + SmartctlHelper bool `json:"smartctlHelper,omitempty"` + PctHelper bool `json:"pctHelper,omitempty"` + ActionRunnerCredentialIssued bool `json:"actionRunnerCredentialIssued,omitempty"` + ActionRunnerCredentialActive bool `json:"actionRunnerCredentialActive,omitempty"` + ActionRunnerRuntimeRole string `json:"actionRunnerRuntimeRole,omitempty"` + ActionRunnerCapability string `json:"actionRunnerCapability,omitempty"` + ActionRunnerBindingVersion string `json:"actionRunnerBindingVersion,omitempty"` + ActionRunnerConnected bool `json:"actionRunnerConnected,omitempty"` + ActionRunnerVersion string `json:"actionRunnerVersion,omitempty"` + ActionRunnerConnectedAt int64 `json:"actionRunnerConnectedAt,omitempty"` + ActionRunnerReceiptProtocol int `json:"actionRunnerReceiptProtocol,omitempty"` + ActionRunnerPreflightProtocol int `json:"actionRunnerPreflightProtocol,omitempty"` + ActionRunnerDockerObservationProtocol int `json:"actionRunnerDockerObservationProtocol,omitempty"` } type AgentFleetDiagnosticReason struct { @@ -964,6 +977,7 @@ func agentFleetPrivilegeForSubject(subject agentFleetSubject, inventory agentFle RunningAsRoot: privilege.RunningAsRoot, ServiceUser: strings.TrimSpace(privilege.ServiceUser), CommandAuthority: strings.TrimSpace(privilege.CommandAuthority), + TypedHelper: privilege.TypedHelper, SmartctlHelper: privilege.SmartctlHelper, PctHelper: privilege.PctHelper, } diff --git a/internal/monitoring/agent_fleet_doctor_test.go b/internal/monitoring/agent_fleet_doctor_test.go index 165315cb1..95ae01866 100644 --- a/internal/monitoring/agent_fleet_doctor_test.go +++ b/internal/monitoring/agent_fleet_doctor_test.go @@ -442,6 +442,7 @@ func TestAgentFleetDiagnosticsSurfacesPrivilegeProfileWithoutDegradingHealth(t * RunningAsRoot: false, ServiceUser: "pulse-agent", CommandAuthority: "monitoring-only", + TypedHelper: true, SmartctlHelper: true, PctHelper: false, }, @@ -455,6 +456,7 @@ func TestAgentFleetDiagnosticsSurfacesPrivilegeProfileWithoutDegradingHealth(t * } if agent.Privilege.RunningAsRoot || agent.Privilege.ServiceUser != "pulse-agent" || agent.Privilege.CommandAuthority != "monitoring-only" || + !agent.Privilege.TypedHelper || !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 c6e65a5e3..27a990525 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -3469,6 +3469,7 @@ func convertAgentPrivilegeStatus(value *agentshost.PrivilegeStatus) *models.Agen RunningAsRoot: value.RunningAsRoot, ServiceUser: strings.TrimSpace(value.ServiceUser), CommandAuthority: strings.TrimSpace(value.CommandAuthority), + TypedHelper: value.TypedHelper, SmartctlHelper: value.SmartctlHelper, PctHelper: value.PctHelper, } diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index 1113ce101..2270513b0 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -5488,6 +5488,7 @@ func TestApplyHostReportCarriesAgentPrivilegeProfile(t *testing.T) { Privilege: &agentshost.PrivilegeStatus{ RunningAsRoot: false, ServiceUser: " pulse-agent ", + TypedHelper: true, SmartctlHelper: true, }, }, @@ -5503,6 +5504,7 @@ func TestApplyHostReportCarriesAgentPrivilegeProfile(t *testing.T) { if host.AgentPrivilege == nil || host.AgentPrivilege.RunningAsRoot || host.AgentPrivilege.ServiceUser != "pulse-agent" || + !host.AgentPrivilege.TypedHelper || !host.AgentPrivilege.SmartctlHelper || host.AgentPrivilege.PctHelper { t.Fatalf("ingested privilege = %+v", host.AgentPrivilege) diff --git a/pkg/agents/host/report.go b/pkg/agents/host/report.go index cbcd232dd..61dc39008 100644 --- a/pkg/agents/host/report.go +++ b/pkg/agents/host/report.go @@ -179,6 +179,10 @@ type PrivilegeStatus struct { RunningAsRoot bool `json:"runningAsRoot"` ServiceUser string `json:"serviceUser,omitempty"` CommandAuthority string `json:"commandAuthority,omitempty"` + // TypedHelper reports that the collector is configured to use the local + // no-network privilege helper socket. It reports posture only and grants no + // authority by itself. + TypedHelper bool `json:"typedHelper,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 f1e0f0415..ce6815b60 100644 --- a/pkg/agents/host/report_test.go +++ b/pkg/agents/host/report_test.go @@ -136,6 +136,7 @@ func TestAgentInfoPrivilegeStatusRoundTrip(t *testing.T) { RunningAsRoot: false, ServiceUser: "pulse-agent", CommandAuthority: "monitoring-only", + TypedHelper: true, SmartctlHelper: true, PctHelper: false, }, @@ -152,6 +153,7 @@ func TestAgentInfoPrivilegeStatusRoundTrip(t *testing.T) { decoded.Privilege.RunningAsRoot || decoded.Privilege.ServiceUser != "pulse-agent" || decoded.Privilege.CommandAuthority != "monitoring-only" || + !decoded.Privilege.TypedHelper || !decoded.Privilege.SmartctlHelper || decoded.Privilege.PctHelper { t.Fatalf("privilege round trip = %+v", decoded.Privilege)