Expose secure agent runtime posture

This commit is contained in:
Pulse Test
2026-08-30 00:38:44 +01:00
parent 28be70071e
commit 5b2e377843
32 changed files with 1605 additions and 72 deletions
+33
View File
@@ -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.
+23
View File
@@ -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 <private-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
@@ -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
@@ -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
@@ -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
@@ -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,
@@ -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,
+22 -25
View File
@@ -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"
]
}
+127 -11
View File
@@ -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
@@ -90,6 +90,58 @@ curl -fsSL http://<pulse-ip>: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 <private-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 <private-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
@@ -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',
}),
});
});
});
@@ -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<ActionRunnerCredentialResponse> {
return apiFetchJSON('/api/agents/action-runner/credential', {
method: 'POST',
body: JSON.stringify(request),
});
}
}
@@ -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<InfrastructureAgentDoctorP
new Map(),
);
const [reenrollPendingId, setReenrollPendingId] = createSignal('');
const [runnerCredentialPendingKey, setRunnerCredentialPendingKey] = createSignal('');
const [issuedRunnerCredentialKeys, setIssuedRunnerCredentialKeys] = createSignal<
ReadonlySet<string>
>(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<InfrastructureAgentDoctorP
</Show>
</p>
<div class="space-y-2 rounded-md border border-border bg-surface px-3 py-3 text-xs">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-base-content">Security posture</span>
<Show when={target.safeCollector}>
<span class="rounded-full bg-emerald-100 px-2 py-0.5 font-medium text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200">
Safe collector confirmed
</span>
</Show>
</div>
<Show when={target.safeProfileGuidance}>
<p class="leading-5 text-muted">{target.safeProfileGuidance}</p>
</Show>
<Show
when={
!target.safeCollector &&
target.status !== 'removed' &&
target.commandPlatform === 'linux' &&
target.actionRunnerAgentId &&
target.actionRunnerHostname
}
>
<div class="space-y-2">
<div>
<div class="mb-1 font-medium text-base-content">
Inspect current service profile
</div>
<div class="relative">
<CommandCopyButton
onClick={() =>
void copyCommand(
buildSafeCollectorInspectCommand({
pulseUrl: operations.selectedAgentUrl(),
insecure: operations.insecureMode(),
customCaPath: operations.customCaPath(),
}),
)
}
label="Copy safe-profile inspection command"
/>
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
<code>
{buildSafeCollectorInspectCommand({
pulseUrl: operations.selectedAgentUrl(),
insecure: operations.insecureMode(),
customCaPath: operations.customCaPath(),
})}
</code>
</pre>
</div>
</div>
<div>
<div class="mb-1 font-medium text-base-content">
Apply reviewed safe collector profile
</div>
<div class="relative">
<CommandCopyButton
onClick={() =>
void copyCommand(
buildSafeCollectorApplyCommand({
pulseUrl: operations.selectedAgentUrl(),
insecure: operations.insecureMode(),
customCaPath: operations.customCaPath(),
}),
)
}
label="Copy safe-profile apply command"
/>
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
<code>
{buildSafeCollectorApplyCommand({
pulseUrl: operations.selectedAgentUrl(),
insecure: operations.insecureMode(),
customCaPath: operations.customCaPath(),
})}
</code>
</pre>
</div>
</div>
</div>
</Show>
<Show when={target.actionRunnerPosture.length > 0}>
<ul class="list-disc space-y-1 pl-5 text-muted">
<For each={target.actionRunnerPosture}>
{(fact) => <li>{fact}</li>}
</For>
</ul>
</Show>
<Show
when={target.actionRunnerCredentialEligible}
fallback={
<Show when={target.actionRunnerCredentialBlockReason}>
<p class="text-muted">
Action runner: {target.actionRunnerCredentialBlockReason}
</p>
</Show>
}
>
<Button
type="button"
variant="outline"
size="sm"
isLoading={runnerCredentialPendingKey() === target.key}
disabled={
Boolean(runnerCredentialReveal()) ||
issuedRunnerCredentialKeys().has(target.key)
}
onClick={() => void issueActionRunnerCredential(target)}
>
{issuedRunnerCredentialKeys().has(target.key)
? 'Credential issued for this page session'
: target.actionRunnerCredentialAction === 'rotate'
? 'Rotate action-runner credential'
: 'Issue one-time action-runner credential'}
</Button>
</Show>
</div>
<Show
when={
runnerCredentialReveal()?.targetKey === target.key
? runnerCredentialReveal()
: null
}
>
{(reveal) => {
const tokenFileCommand = () => buildActionRunnerTokenFileCommand();
const installCommand = () =>
buildActionRunnerInstallCommand({
pulseUrl: operations.selectedAgentUrl(),
agentId: target.actionRunnerAgentId!,
hostname: target.actionRunnerHostname!,
insecure: operations.insecureMode(),
customCaPath: operations.customCaPath(),
});
return (
<section class="space-y-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-3 text-xs text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100">
<div class="font-semibold">Save this credential now</div>
<p class="leading-5">
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.
</p>
<div>
<div class="mb-1 font-medium">
1. Start a private token prompt
</div>
<div class="relative">
<CommandCopyButton
onClick={() => void copyCommand(tokenFileCommand())}
label="Copy private token-file command"
/>
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
<code>{tokenFileCommand()}</code>
</pre>
</div>
</div>
<div>
<div class="mb-1 font-medium">
2. Copy this credential and paste it at the prompt
</div>
<div class="relative">
<CommandCopyButton
onClick={() => void copyCommand(reveal().token)}
title="Copy one-time action-runner credential"
label={`Copy action-runner credential for ${target.displayName}`}
/>
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
<code>{reveal().token}</code>
</pre>
</div>
</div>
<div>
<div class="mb-1 font-medium">
3. Install the separate runner
</div>
<div class="relative">
<CommandCopyButton
onClick={() => void copyCommand(installCommand())}
label="Copy action-runner installer command"
/>
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
<code>{installCommand()}</code>
</pre>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setRunnerCredentialReveal(null)}
>
Clear credential from this page
</Button>
</section>
);
}}
</Show>
<Show
when={target.reasons.length > 0}
fallback={
@@ -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<typeof import('@/api/agentDiagnostics')>('@/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> = {},
): 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(() => <InfrastructureAgentDoctorPage targets={[targetFixture()]} />);
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(() => (
<InfrastructureAgentDoctorPage
targets={[
targetFixture({
safeCollector: false,
safeProfileGuidance: 'Safe collector profile is not confirmed.',
actionRunnerCredentialEligible: false,
actionRunnerCredentialBlockReason: 'Confirm the safe collector profile first.',
}),
]}
/>
));
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(() => (
<InfrastructureAgentDoctorPage
targets={[
targetFixture({
actionRunnerPosture: ['Credential issued (active)', 'Runner not connected'],
actionRunnerCredentialAction: 'rotate',
}),
]}
/>
));
expect(
screen.getByRole('button', { name: 'Rotate action-runner credential' }),
).toBeInTheDocument();
});
});
@@ -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('</dev/tty');
expect(prompt).toContain('chmod 0600');
expect(prompt).not.toContain(secret);
const install = buildActionRunnerInstallCommand({
pulseUrl: 'https://pulse.example.test',
agentId: 'host-1',
hostname: 'host-1.local',
customCaPath: '/etc/pulse/ca.pem',
});
expect(install).toContain('--preflight-only');
expect(install).toContain('--update');
expect(install).toContain('--non-interactive');
expect(install).toContain('--least-privilege');
expect(install).toContain('--enable-privileged-helper');
expect(install).toContain('--enable-action-runner');
expect(install).toContain('--action-token-file');
expect(install).toContain("--agent-id 'host-1'");
expect(install).toContain("--hostname 'host-1.local'");
expect(install).toContain("--cacert '/etc/pulse/ca.pem'");
expect(install).not.toContain(secret);
expect(install).not.toContain('--action-token ');
const inspect = buildSafeCollectorInspectCommand({
pulseUrl: 'https://pulse.example.test',
});
expect(inspect).toContain('--preflight-only');
expect(inspect).toContain('--safe-profile-inspect');
expect(inspect).not.toContain('--update');
const apply = buildSafeCollectorApplyCommand({
pulseUrl: 'https://pulse.example.test',
});
expect(apply).toContain('--preflight-only');
expect(apply).toContain('--safe-profile-apply');
expect(apply).not.toContain('--update');
});
it('turns a missing credential into a token-gated authentication repair', () => {
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,
});
@@ -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 '\\n' >/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}${
@@ -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) {
+110 -1
View File
@@ -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))
}
+57
View File
@@ -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)
}
}
+25 -2
View File
@@ -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
})
}
+82
View File
@@ -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",
+11 -1
View File
@@ -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 {
+30
View File
@@ -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{
+1
View File
@@ -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")) != "",
}
+4 -2
View File
@@ -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)
}
}
+1
View File
@@ -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"`
}
+25 -11
View File
@@ -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,
}
@@ -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)
}
+1
View File
@@ -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,
}
@@ -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)
+4
View File
@@ -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.
+2
View File
@@ -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)