Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
47b15f8
docs: add agent hardening guide with sudoers and capabilities
retr0h Apr 2, 2026
802cef6
docs: add privilege escalation design to agent hardening guide
retr0h Apr 2, 2026
13356b9
docs: remove agent-hardening from sidebar docs
retr0h Apr 2, 2026
17026a0
docs: add agent privilege escalation design spec
retr0h Apr 2, 2026
a9bb59f
docs: add agent privilege escalation implementation plan
retr0h Apr 2, 2026
59077ce
feat(agent): add privilege escalation config
retr0h Apr 2, 2026
6cc7ac2
feat(agent): add preflight checks for sudo and capabilities
retr0h Apr 2, 2026
8cf8318
refactor(service): use RunPrivilegedCmd for write operations
retr0h Apr 2, 2026
018342f
refactor(providers): use RunPrivilegedCmd for sysctl, host, timezone,…
retr0h Apr 2, 2026
19244eb
refactor(providers): use RunPrivilegedCmd for user, apt, power, certi…
retr0h Apr 2, 2026
7046548
docs: add agent hardening feature page and config reference
retr0h Apr 2, 2026
db7cd83
chore(agent): fix lint errors in preflight
retr0h Apr 2, 2026
8ab74d1
chore(config): add privilege_escalation to dev config
retr0h Apr 2, 2026
0875a25
chore(config): uncomment privilege_escalation, enable preflight
retr0h Apr 2, 2026
3ec659f
refactor(agent): remove preflight config, verify automatically
retr0h Apr 2, 2026
6914cec
refactor(agent): simplify privilege escalation to single enabled field
retr0h Apr 2, 2026
491cabb
refactor(exec): add CommandExecutor interface for testable sudo
retr0h Apr 2, 2026
ead68bb
refactor(exec): add CommandExecutor interface for testable sudo
retr0h Apr 2, 2026
f0b6110
test(agent): add coverage for readCapEff and preflight Start paths
retr0h Apr 2, 2026
62ac413
test(agent): cover scanner.Err path in readCapEff
retr0h Apr 2, 2026
680706a
style(agent): fix multi-line function call formatting
retr0h Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/agent_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func setupAgent(
log.Info("running on darwin")
}

execManager := exec.New(log)
execManager := exec.New(log, appConfig.Agent.PrivilegeEscalation.Enabled)

// --- Node providers ---
var hostProvider nodeHost.Provider
Expand Down Expand Up @@ -303,6 +303,7 @@ func setupAgent(
registry,
b.registryKV,
b.factsKV,
execManager,
)

enabledOrDisabled := func(enabled bool) string {
Expand Down
4 changes: 4 additions & 0 deletions configs/osapi.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ agent:
metrics:
enabled: true
port: 9091
# Least-privilege mode. When enabled, write commands use sudo and
# Linux capabilities are verified at startup. See Agent Hardening docs.
privilege_escalation:
enabled: false

nats:
stream:
Expand Down
4 changes: 4 additions & 0 deletions configs/osapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,8 @@ agent:
enabled: true
host: '0.0.0.0'
port: 9091
# Least-privilege mode. When enabled, write commands use sudo and
# Linux capabilities are verified at startup. See Agent Hardening docs.
privilege_escalation:
enabled: false

247 changes: 247 additions & 0 deletions docs/docs/sidebar/features/agent-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
---
sidebar_position: 20
sidebar_label: Agent Hardening
---

# Agent Hardening

OSAPI supports running the agent as an unprivileged user with config-driven
privilege escalation for write operations. Reads run as the agent's own user.
Writes use `sudo` when configured. Linux capabilities provide an alternative for
file-level access without a full sudo setup. When either option is enabled, the
agent automatically verifies the configuration at startup before accepting any
jobs.

When none of these options are enabled, the agent behaves as before — commands
run as the current user, root or otherwise.

## Configuration

```yaml
agent:
privilege_escalation:
# Activate least-privilege mode: sudo for write commands
# and capability verification at startup.
enabled: false
```

| Field | Type | Default | Description |
| --------- | ---- | ------- | ----------------------------------------------------------------- |
| `enabled` | bool | false | Activate sudo for write commands and capability checks at startup |

## How It Works

The exec manager exposes two execution paths:

- **`RunCmd`** — runs the command as the agent's current user. Used for all read
operations (listing services, reading kernel parameters, querying package
state, etc.).
- **`RunPrivilegedCmd`** — runs the command with `sudo` prepended when
`privilege_escalation.enabled: true`. When disabled, this is identical to
`RunCmd`.

Providers call `RunCmd` for reads and `RunPrivilegedCmd` for writes. The
providers themselves have no knowledge of whether `sudo` is enabled — the exec
manager handles it transparently.

```go
// Read — always unprivileged
output, _ := d.execManager.RunCmd("systemctl", []string{"is-active", name})

// Write — elevated when configured
_, err := d.execManager.RunPrivilegedCmd(
"systemctl", []string{"start", name})
```

## Sudoers Drop-In

Create `/etc/sudoers.d/osapi-agent` with the following content to allow the
`osapi` system user to run write commands without a password:

```sudoers
# Service management
osapi ALL=(root) NOPASSWD: /usr/bin/systemctl start *
osapi ALL=(root) NOPASSWD: /usr/bin/systemctl stop *
osapi ALL=(root) NOPASSWD: /usr/bin/systemctl restart *
osapi ALL=(root) NOPASSWD: /usr/bin/systemctl enable *
osapi ALL=(root) NOPASSWD: /usr/bin/systemctl disable *
osapi ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload

# Kernel parameters
osapi ALL=(root) NOPASSWD: /usr/sbin/sysctl -p *
osapi ALL=(root) NOPASSWD: /usr/sbin/sysctl --system

# Timezone
osapi ALL=(root) NOPASSWD: /usr/bin/timedatectl set-timezone *

# Hostname
osapi ALL=(root) NOPASSWD: /usr/bin/hostnamectl set-hostname *

# NTP
osapi ALL=(root) NOPASSWD: /usr/bin/chronyc reload sources

# User and group management
osapi ALL=(root) NOPASSWD: /usr/sbin/useradd *
osapi ALL=(root) NOPASSWD: /usr/sbin/usermod *
osapi ALL=(root) NOPASSWD: /usr/sbin/userdel *
osapi ALL=(root) NOPASSWD: /usr/sbin/groupadd *
osapi ALL=(root) NOPASSWD: /usr/sbin/groupdel *
osapi ALL=(root) NOPASSWD: /usr/bin/gpasswd *
osapi ALL=(root) NOPASSWD: /usr/bin/chown *
osapi ALL=(root) NOPASSWD: /bin/sh -c echo *

# Package management
osapi ALL=(root) NOPASSWD: /usr/bin/apt-get install *
osapi ALL=(root) NOPASSWD: /usr/bin/apt-get remove *
osapi ALL=(root) NOPASSWD: /usr/bin/apt-get update

# Certificate trust store
osapi ALL=(root) NOPASSWD: /usr/sbin/update-ca-certificates

# Power management
osapi ALL=(root) NOPASSWD: /sbin/shutdown *
```

Validate the file with `sudo visudo -c -f /etc/sudoers.d/osapi-agent` before
reloading.

## Linux Capabilities

As an alternative to `sudo` for file-level access, grant the agent binary
specific Linux capabilities:

```bash
sudo setcap \
'cap_dac_read_search+ep cap_dac_override+ep cap_fowner+ep cap_kill+ep' \
/usr/local/bin/osapi
```

When `privilege_escalation.enabled: true`, the agent reads `/proc/self/status`
at startup and checks the `CapEff` bitmask for the required bits:

| Capability | Bit | Purpose |
| --------------------- | --- | ------------------------------- |
| `CAP_DAC_READ_SEARCH` | 2 | Read restricted files |
| `CAP_DAC_OVERRIDE` | 1 | Write files regardless of owner |
| `CAP_FOWNER` | 3 | Change file ownership |
| `CAP_KILL` | 5 | Signal any process |

If any required capability is missing the agent logs the failure and exits with
a non-zero status.

## Systemd Unit File

The recommended way to run the agent as an unprivileged user with capabilities
preserved across restarts:

```ini
[Unit]
Description=OSAPI Agent
After=network.target

[Service]
Type=simple
User=osapi
Group=osapi
ExecStart=/usr/local/bin/osapi agent start
Restart=always
RestartSec=5
AmbientCapabilities=CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL
CapabilityBoundingSet=CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_KILL
SecureBits=keep-caps
NoNewPrivileges=no
PrivateTmp=true

[Install]
WantedBy=multi-user.target
```

`AmbientCapabilities` grants the capabilities to the process without requiring
`setcap` on the binary. `NoNewPrivileges=no` is required so that `sudo` (if also
used) can elevate correctly.

## Preflight Checks

When `enabled` is true, the agent automatically runs a verification pass during
`agent start` before subscribing to NATS. Checks are sequential: sudo first,
then capabilities. If any check fails, the agent logs the failure and exits with
a non-zero status.

The sudo check runs `sudo -n <command> --version` (or `sudo -n which <command>`
for commands that do not support `--version`). The `-n` flag makes `sudo` fail
immediately if a password prompt would be required, confirming that the sudoers
entry is present and correct.

Example output:

```
OSAPI Agent Preflight Check
─────────────────────────────
Sudo access:
✓ systemctl ✓ sysctl ✓ timedatectl
✓ hostnamectl ✓ chronyc ✓ useradd
✓ usermod ✓ userdel ✓ groupadd
✓ groupdel ✓ gpasswd ✓ chown
✓ apt-get ✓ shutdown ✓ update-ca-certificates
✗ sh (sudoers rule missing)

Capabilities:
✓ CAP_DAC_READ_SEARCH ✓ CAP_DAC_OVERRIDE
✓ CAP_FOWNER ✓ CAP_KILL

Result: FAILED (1 error)
- sudo: sh not configured in /etc/sudoers.d/osapi-agent
```

## Command Reference

### Write Operations (use `RunPrivilegedCmd`)

| Command | Domain |
| -------------------------- | ----------- |
| `systemctl start/stop/…` | Service |
| `systemctl daemon-reload` | Service |
| `sysctl -p`, `--system` | Sysctl |
| `timedatectl set-timezone` | Timezone |
| `hostnamectl set-hostname` | Hostname |
| `chronyc reload sources` | NTP |
| `useradd`, `usermod` | User |
| `userdel -r` | User |
| `groupadd`, `groupdel` | Group |
| `gpasswd -M` | Group |
| `chown -R` | SSH Key |
| `apt-get install/remove` | Package |
| `apt-get update` | Package |
| `update-ca-certificates` | Certificate |
| `shutdown -r/-h` | Power |
| `sh -c "echo … chpasswd"` | User |

### Read Operations (use `RunCmd`)

| Command | Domain |
| --------------------------- | -------- |
| `systemctl list-units` | Service |
| `systemctl list-unit-files` | Service |
| `systemctl show` | Service |
| `systemctl is-active` | Service |
| `systemctl is-enabled` | Service |
| `sysctl -n` | Sysctl |
| `timedatectl show` | Timezone |
| `hostnamectl hostname` | Hostname |
| `journalctl` | Log |
| `chronyc tracking` | NTP |
| `chronyc sources -c` | NTP |
| `id -Gn` | User |
| `passwd -S` | User |
| `dpkg-query` | Package |
| `apt list --upgradable` | Package |
| `date +%:z` | Timezone |

## What Is Not Changed

- **Controller and NATS server** — already run unprivileged, no changes needed.
- **`command exec` and `command shell`** — these endpoints execute arbitrary
user-provided commands and inherit whatever privileges the agent has. They are
gated by the `command:execute` RBAC permission.
- **Docker provider** — talks to the Docker API socket, not system commands. The
`osapi` user needs to be in the `docker` group.
6 changes: 6 additions & 0 deletions docs/docs/sidebar/usage/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ uppercased:
| `agent.process_conditions.high_cpu_percent` | `OSAPI_AGENT_PROCESS_CONDITIONS_HIGH_CPU_PERCENT` |
| `agent.metrics.enabled` | `OSAPI_AGENT_METRICS_ENABLED` |
| `agent.metrics.port` | `OSAPI_AGENT_METRICS_PORT` |
| `agent.privilege_escalation.enabled` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_ENABLED` |

Environment variables take precedence over file values.

Expand Down Expand Up @@ -504,6 +505,10 @@ agent:
enabled: true
# Port the metrics server listens on.
port: 9091
# Least-privilege mode. When enabled, write commands use sudo and
# Linux capabilities are verified at startup.
privilege_escalation:
enabled: false
```

## Section Reference
Expand Down Expand Up @@ -695,6 +700,7 @@ When enabled, the port also serves `/health` (liveness) and `/health/ready`
| `labels` | map[string]string | Key-value pairs for label-based routing |
| `metrics.enabled` | bool | Enable the metrics server (default: true) |
| `metrics.port` | int | Port the metrics server listens on (default: 9091) |
| `privilege_escalation.enabled` | bool | Activate sudo and capability checks (default false) |

When `metrics.enabled` is true, the port also serves `/health` (liveness) and
`/health/ready` (readiness) probes without authentication.
5 changes: 5 additions & 0 deletions docs/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ const config: Config = {
type: 'doc',
label: 'Service Management',
docId: 'sidebar/features/service-management'
},
{
type: 'doc',
label: 'Agent Hardening',
docId: 'sidebar/features/agent-hardening'
}
]
},
Expand Down
Loading
Loading