From 47b15f866ccf0c1f1899dc5ea042272d7484d6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 13:43:43 -0700 Subject: [PATCH 01/21] docs: add agent hardening guide with sudoers and capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference documentation for running the agent as an unprivileged user. Includes sudoers drop-in, capability setup, systemd unit file, and full command privilege reference table. Code changes for privilege escalation support are tracked separately. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../sidebar/development/agent-hardening.md | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 docs/docs/sidebar/development/agent-hardening.md diff --git a/docs/docs/sidebar/development/agent-hardening.md b/docs/docs/sidebar/development/agent-hardening.md new file mode 100644 index 000000000..e094dd856 --- /dev/null +++ b/docs/docs/sidebar/development/agent-hardening.md @@ -0,0 +1,223 @@ +# Agent Hardening + +This guide covers running the OSAPI agent as an unprivileged user with minimal +permissions. By default the agent runs as root, but production deployments +should use a dedicated `osapi` user with sudo rules and Linux capabilities. + +## Overview + +The agent executes system commands via its exec manager. Some commands need root +privileges (user management, service control), while others work unprivileged +(package queries, reading logs). The hardening strategy: + +1. Run the agent as a dedicated `osapi` user +2. Grant sudo access only for specific commands +3. Set Linux capabilities on the binary for direct file operations + +## Create the Service Account + +```bash +sudo useradd --system --no-create-home --shell /usr/sbin/nologin osapi +``` + +## Sudoers Configuration + +Create `/etc/sudoers.d/osapi-agent` with the following content. This grants +the `osapi` user passwordless sudo access to exactly the commands the agent +needs — nothing more. + +```sudoers +# /etc/sudoers.d/osapi-agent +# OSAPI agent privilege escalation rules. +# Only the commands listed here can be run as root. + +# 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 +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl is-active * +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl is-enabled * +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl show * +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl list-units * +osapi ALL=(root) NOPASSWD: /usr/bin/systemctl list-unit-files * + +# Kernel parameters +osapi ALL=(root) NOPASSWD: /usr/sbin/sysctl -p * +osapi ALL=(root) NOPASSWD: /usr/sbin/sysctl --system +osapi ALL=(root) NOPASSWD: /usr/sbin/sysctl -n * + +# Timezone +osapi ALL=(root) NOPASSWD: /usr/bin/timedatectl show * +osapi ALL=(root) NOPASSWD: /usr/bin/timedatectl set-timezone * + +# Hostname +osapi ALL=(root) NOPASSWD: /usr/bin/hostnamectl hostname +osapi ALL=(root) NOPASSWD: /usr/bin/hostnamectl set-hostname * + +# System logs (read-only, but journalctl often needs root for full access) +osapi ALL=(root) NOPASSWD: /usr/bin/journalctl * + +# NTP +osapi ALL=(root) NOPASSWD: /usr/bin/chronyc tracking +osapi ALL=(root) NOPASSWD: /usr/bin/chronyc sources * +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 * +``` + +Set permissions: + +```bash +sudo cp osapi-agent.sudoers /etc/sudoers.d/osapi-agent +sudo chmod 0440 /etc/sudoers.d/osapi-agent +sudo visudo -c # validate syntax +``` + +## Linux Capabilities + +Capabilities grant the agent binary specific privileges without full root +access. These cover direct file operations that don't go through external +commands. + +```bash +sudo setcap \ + 'cap_dac_read_search+ep cap_dac_override+ep cap_fowner+ep cap_kill+ep' \ + /usr/local/bin/osapi +``` + +| Capability | Purpose | +| ---------------------- | ------------------------------------------------ | +| `cap_dac_read_search` | Read any file (e.g., `/etc/shadow` for user info)| +| `cap_dac_override` | Write files regardless of ownership (unit files, | +| | cron files, sysctl conf, CA certs) | +| `cap_fowner` | Change file ownership (SSH key directories) | +| `cap_kill` | Send signals to any process | + +Verify capabilities are set: + +```bash +getcap /usr/local/bin/osapi +# Expected: /usr/local/bin/osapi cap_dac_override,cap_dac_read_search,cap_fowner,cap_kill=ep +``` + +## Systemd Unit File + +Run the agent as the `osapi` user with ambient capabilities: + +```ini +# /etc/systemd/system/osapi-agent.service +[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 + +# Capabilities +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 + +# Hardening +NoNewPrivileges=no +ProtectSystem=false +ProtectHome=false +ReadWritePaths=/etc/systemd/system /etc/sysctl.d /etc/cron.d +ReadWritePaths=/usr/local/share/ca-certificates +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +``` + +Note: `NoNewPrivileges=no` is required because the agent uses `sudo` for +privileged commands. If `NoNewPrivileges=yes` were set, `sudo` would be +blocked. + +## Command Privilege Reference + +Commands the agent executes, grouped by privilege requirement: + +### Requires sudo + +| Command | Domain | Operation | +| ------------------------ | ----------- | ----------------- | +| `systemctl start/stop/…` | Service | Lifecycle control | +| `systemctl daemon-reload`| Service | Unit file reload | +| `sysctl -p`, `--system` | Sysctl | Apply parameters | +| `timedatectl set-timezone`| Timezone | Set timezone | +| `hostnamectl set-hostname`| Hostname | Set hostname | +| `chronyc reload sources` | NTP | Apply NTP config | +| `useradd`, `usermod` | User | User management | +| `userdel -r` | User | Delete user | +| `groupadd`, `groupdel` | Group | Group management | +| `gpasswd -M` | Group | Set members | +| `chown -R` | SSH Key | Fix ownership | +| `apt-get install/remove` | Package | Install/remove | +| `apt-get update` | Package | Update index | +| `update-ca-certificates` | Certificate | Rebuild trust | +| `shutdown -r/-h` | Power | Reboot/shutdown | +| `sh -c "echo … chpasswd"`| User | Set password | + +### Runs unprivileged (or with capabilities) + +| Command | Domain | Operation | +| ------------------------ | ----------- | ----------------- | +| `systemctl list-units` | Service | List services | +| `systemctl list-unit-files`| Service | List unit files | +| `systemctl show` | Service | Get service info | +| `systemctl is-active` | Service | Check status | +| `systemctl is-enabled` | Service | Check enabled | +| `sysctl -n` | Sysctl | Read parameter | +| `timedatectl show` | Timezone | Read timezone | +| `hostnamectl hostname` | Hostname | Read hostname | +| `journalctl` | Log | Query logs | +| `chronyc tracking` | NTP | Read NTP status | +| `chronyc sources -c` | NTP | List sources | +| `id -Gn` | User | Get user groups | +| `passwd -S` | User | Password status | +| `dpkg-query` | Package | Query packages | +| `apt list --upgradable` | Package | Check updates | +| `date +%:z` | Timezone | Read UTC offset | + +## Verification + +After setup, verify the agent can operate: + +```bash +# Switch to the osapi user and test +sudo -u osapi osapi agent start --dry-run + +# Verify sudo works for allowed commands +sudo -u osapi sudo systemctl list-units --type=service --no-pager + +# Verify sudo is denied for non-whitelisted commands +sudo -u osapi sudo rm /etc/passwd # should be denied +``` From 802cef675dcfcb7fa0e385e12c5ea6cde3c295f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 13:46:37 -0700 Subject: [PATCH 02/21] docs: add privilege escalation design to agent hardening guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add exec manager RunPrivilegedCmd design, config schema for privilege_escalation (sudo, capabilities, preflight), preflight check design with example output, and updated command reference showing which calls use RunCmd vs RunPrivilegedCmd. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../sidebar/development/agent-hardening.md | 299 +++++++++++++----- 1 file changed, 228 insertions(+), 71 deletions(-) diff --git a/docs/docs/sidebar/development/agent-hardening.md b/docs/docs/sidebar/development/agent-hardening.md index e094dd856..70a9748c8 100644 --- a/docs/docs/sidebar/development/agent-hardening.md +++ b/docs/docs/sidebar/development/agent-hardening.md @@ -11,8 +11,175 @@ privileges (user management, service control), while others work unprivileged (package queries, reading logs). The hardening strategy: 1. Run the agent as a dedicated `osapi` user -2. Grant sudo access only for specific commands -3. Set Linux capabilities on the binary for direct file operations +2. Enable privilege escalation in the config +3. Grant sudo access only for specific commands +4. Set Linux capabilities on the binary for direct file operations +5. The agent verifies its privileges at startup before accepting jobs + +## Configuration + +```yaml +agent: + privilege_escalation: + # Enable sudo for write operations. When true, the exec manager + # prepends "sudo" to commands that modify system state. Read + # operations are never elevated. + sudo: true + # Enable capability verification at startup. When true, the agent + # checks that the required Linux capabilities are set on the + # binary before accepting jobs. + capabilities: true + # Run preflight checks at startup. Verifies sudo access and + # capabilities are correctly configured. The agent refuses to + # start if checks fail. + preflight: true +``` + +When `privilege_escalation` is not set or all fields are false, the agent +behaves as before — commands run as the current user with no elevation. + +## Design: Exec Manager Changes + +The exec `Manager` interface gains a `RunPrivilegedCmd` method. Providers call +it for write operations and `RunCmd` for reads: + +```go +type Manager interface { + // RunCmd executes a command as the current user. Use for read + // operations that don't modify system state. + RunCmd( + name string, + args []string, + ) (string, error) + + // RunPrivilegedCmd executes a command with privilege escalation. + // When sudo is enabled in config, prepends "sudo" to the command. + // When sudo is disabled, behaves identically to RunCmd. + // Use for write operations that modify system state. + RunPrivilegedCmd( + name string, + args []string, + ) (string, error) + + // RunCmdFull executes a command with separate stdout/stderr + // capture, an optional working directory, and a timeout. + RunCmdFull( + name string, + args []string, + cwd string, + timeout int, + ) (*CmdResult, error) +} +``` + +The `Exec` struct gains a `sudo bool` field set from config: + +```go +type Exec struct { + logger *slog.Logger + sudo bool +} + +func New( + logger *slog.Logger, + sudo bool, +) *Exec { + return &Exec{ + logger: logger.With(slog.String("subsystem", "exec")), + sudo: sudo, + } +} + +func (e *Exec) RunPrivilegedCmd( + name string, + args []string, +) (string, error) { + if e.sudo { + args = append([]string{name}, args...) + name = "sudo" + } + return e.RunCmdImpl(name, args, "") +} +``` + +### Provider changes + +Each provider's write operations change from `RunCmd` to `RunPrivilegedCmd`. +Read operations stay on `RunCmd`. Example from the service provider: + +```go +// Read — no elevation +output, _ := d.execManager.RunCmd( + "systemctl", + []string{"is-active", unitName}, +) + +// Write — elevated when configured +_, err := d.execManager.RunPrivilegedCmd( + "systemctl", + []string{"start", unitName}, +) +``` + +The mock `Manager` interface gains `RunPrivilegedCmd`. Tests for write +operations expect `RunPrivilegedCmd`, tests for reads expect `RunCmd`. This +enforces the read/write distinction at the test level — using the wrong method +causes a mock expectation failure. + +## Design: Preflight Checks + +When `preflight: true`, the agent runs checks during `agent start` before +accepting jobs. If any check fails, the agent logs the failure and exits. + +### Sudo verification + +For each command in the sudo whitelist, run `sudo -n --help` (or +equivalent no-op) to verify the sudoers rule is configured. The `-n` flag +makes sudo fail immediately if a password would be required. + +```go +func (p *Preflight) CheckSudo( + commands []string, +) []PreflightResult { + // For each command, run "sudo -n --version" or + // similar no-op to verify access without side effects. +} +``` + +### Capability verification + +Read `/proc/self/status` and parse the `CapEff` line to check that required +capability bits are set: + +```go +func (p *Preflight) CheckCapabilities( + required []capability, +) []PreflightResult { + // Parse /proc/self/status CapEff bitmask + // Check each required capability bit +} +``` + +### Preflight 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 +``` ## Create the Service Account @@ -22,9 +189,8 @@ sudo useradd --system --no-create-home --shell /usr/sbin/nologin osapi ## Sudoers Configuration -Create `/etc/sudoers.d/osapi-agent` with the following content. This grants -the `osapi` user passwordless sudo access to exactly the commands the agent -needs — nothing more. +Create `/etc/sudoers.d/osapi-agent` with the following content. This grants the +`osapi` user passwordless sudo access to exactly the commands the agent needs. ```sudoers # /etc/sudoers.d/osapi-agent @@ -38,31 +204,18 @@ 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 -osapi ALL=(root) NOPASSWD: /usr/bin/systemctl is-active * -osapi ALL=(root) NOPASSWD: /usr/bin/systemctl is-enabled * -osapi ALL=(root) NOPASSWD: /usr/bin/systemctl show * -osapi ALL=(root) NOPASSWD: /usr/bin/systemctl list-units * -osapi ALL=(root) NOPASSWD: /usr/bin/systemctl list-unit-files * # Kernel parameters osapi ALL=(root) NOPASSWD: /usr/sbin/sysctl -p * osapi ALL=(root) NOPASSWD: /usr/sbin/sysctl --system -osapi ALL=(root) NOPASSWD: /usr/sbin/sysctl -n * # Timezone -osapi ALL=(root) NOPASSWD: /usr/bin/timedatectl show * osapi ALL=(root) NOPASSWD: /usr/bin/timedatectl set-timezone * # Hostname -osapi ALL=(root) NOPASSWD: /usr/bin/hostnamectl hostname osapi ALL=(root) NOPASSWD: /usr/bin/hostnamectl set-hostname * -# System logs (read-only, but journalctl often needs root for full access) -osapi ALL=(root) NOPASSWD: /usr/bin/journalctl * - # NTP -osapi ALL=(root) NOPASSWD: /usr/bin/chronyc tracking -osapi ALL=(root) NOPASSWD: /usr/bin/chronyc sources * osapi ALL=(root) NOPASSWD: /usr/bin/chronyc reload sources # User and group management @@ -87,6 +240,9 @@ osapi ALL=(root) NOPASSWD: /usr/sbin/update-ca-certificates osapi ALL=(root) NOPASSWD: /sbin/shutdown * ``` +Note: read-only commands (`systemctl list-units`, `sysctl -n`, `journalctl`, +etc.) are NOT in the sudoers file — they run unprivileged via `RunCmd`. + Set permissions: ```bash @@ -99,7 +255,7 @@ sudo visudo -c # validate syntax Capabilities grant the agent binary specific privileges without full root access. These cover direct file operations that don't go through external -commands. +commands (reads via `avfs`, writes to config directories). ```bash sudo setcap \ @@ -107,13 +263,13 @@ sudo setcap \ /usr/local/bin/osapi ``` -| Capability | Purpose | -| ---------------------- | ------------------------------------------------ | -| `cap_dac_read_search` | Read any file (e.g., `/etc/shadow` for user info)| -| `cap_dac_override` | Write files regardless of ownership (unit files, | -| | cron files, sysctl conf, CA certs) | -| `cap_fowner` | Change file ownership (SSH key directories) | -| `cap_kill` | Send signals to any process | +| Capability | Purpose | +| --------------------- | ------------------------------------------------- | +| `cap_dac_read_search` | Read any file (e.g., `/etc/shadow` for user info) | +| `cap_dac_override` | Write files regardless of ownership (unit files, | +| | cron files, sysctl conf, CA certs) | +| `cap_fowner` | Change file ownership (SSH key directories) | +| `cap_kill` | Send signals to any process | Verify capabilities are set: @@ -163,49 +319,50 @@ blocked. ## Command Privilege Reference -Commands the agent executes, grouped by privilege requirement: - -### Requires sudo - -| Command | Domain | Operation | -| ------------------------ | ----------- | ----------------- | -| `systemctl start/stop/…` | Service | Lifecycle control | -| `systemctl daemon-reload`| Service | Unit file reload | -| `sysctl -p`, `--system` | Sysctl | Apply parameters | -| `timedatectl set-timezone`| Timezone | Set timezone | -| `hostnamectl set-hostname`| Hostname | Set hostname | -| `chronyc reload sources` | NTP | Apply NTP config | -| `useradd`, `usermod` | User | User management | -| `userdel -r` | User | Delete user | -| `groupadd`, `groupdel` | Group | Group management | -| `gpasswd -M` | Group | Set members | -| `chown -R` | SSH Key | Fix ownership | -| `apt-get install/remove` | Package | Install/remove | -| `apt-get update` | Package | Update index | -| `update-ca-certificates` | Certificate | Rebuild trust | -| `shutdown -r/-h` | Power | Reboot/shutdown | -| `sh -c "echo … chpasswd"`| User | Set password | - -### Runs unprivileged (or with capabilities) - -| Command | Domain | Operation | -| ------------------------ | ----------- | ----------------- | -| `systemctl list-units` | Service | List services | -| `systemctl list-unit-files`| Service | List unit files | -| `systemctl show` | Service | Get service info | -| `systemctl is-active` | Service | Check status | -| `systemctl is-enabled` | Service | Check enabled | -| `sysctl -n` | Sysctl | Read parameter | -| `timedatectl show` | Timezone | Read timezone | -| `hostnamectl hostname` | Hostname | Read hostname | -| `journalctl` | Log | Query logs | -| `chronyc tracking` | NTP | Read NTP status | -| `chronyc sources -c` | NTP | List sources | -| `id -Gn` | User | Get user groups | -| `passwd -S` | User | Password status | -| `dpkg-query` | Package | Query packages | -| `apt list --upgradable` | Package | Check updates | -| `date +%:z` | Timezone | Read UTC offset | +Commands the agent executes, grouped by privilege requirement. This determines +whether a provider calls `RunCmd` (read) or `RunPrivilegedCmd` (write). + +### Write operations (use `RunPrivilegedCmd`) + +| Command | Domain | Operation | +| ------------------------- | ----------- | ----------------- | +| `systemctl start/stop/…` | Service | Lifecycle control | +| `systemctl daemon-reload` | Service | Unit file reload | +| `sysctl -p`, `--system` | Sysctl | Apply parameters | +| `timedatectl set-timezone` | Timezone | Set timezone | +| `hostnamectl set-hostname` | Hostname | Set hostname | +| `chronyc reload sources` | NTP | Apply NTP config | +| `useradd`, `usermod` | User | User management | +| `userdel -r` | User | Delete user | +| `groupadd`, `groupdel` | Group | Group management | +| `gpasswd -M` | Group | Set members | +| `chown -R` | SSH Key | Fix ownership | +| `apt-get install/remove` | Package | Install/remove | +| `apt-get update` | Package | Update index | +| `update-ca-certificates` | Certificate | Rebuild trust | +| `shutdown -r/-h` | Power | Reboot/shutdown | +| `sh -c "echo … chpasswd"` | User | Set password | + +### Read operations (use `RunCmd`) + +| Command | Domain | Operation | +| ---------------------------- | ------- | --------------- | +| `systemctl list-units` | Service | List services | +| `systemctl list-unit-files` | Service | List unit files | +| `systemctl show` | Service | Get service info| +| `systemctl is-active` | Service | Check status | +| `systemctl is-enabled` | Service | Check enabled | +| `sysctl -n` | Sysctl | Read parameter | +| `timedatectl show` | Timezone| Read timezone | +| `hostnamectl hostname` | Hostname| Read hostname | +| `journalctl` | Log | Query logs | +| `chronyc tracking` | NTP | Read NTP status | +| `chronyc sources -c` | NTP | List sources | +| `id -Gn` | User | Get user groups | +| `passwd -S` | User | Password status | +| `dpkg-query` | Package | Query packages | +| `apt list --upgradable` | Package | Check updates | +| `date +%:z` | Timezone| Read UTC offset | ## Verification @@ -216,8 +373,8 @@ After setup, verify the agent can operate: sudo -u osapi osapi agent start --dry-run # Verify sudo works for allowed commands -sudo -u osapi sudo systemctl list-units --type=service --no-pager +sudo -u osapi sudo -n systemctl list-units --type=service --no-pager # Verify sudo is denied for non-whitelisted commands -sudo -u osapi sudo rm /etc/passwd # should be denied +sudo -u osapi sudo -n rm /etc/passwd # should be denied ``` From 13356b99b881dbc3a3b61c21cd15f3658fbacf0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 13:51:58 -0700 Subject: [PATCH 03/21] docs: remove agent-hardening from sidebar docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content will live in docs/plans/ as a design spec instead of user-facing documentation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../sidebar/development/agent-hardening.md | 380 ------------------ 1 file changed, 380 deletions(-) delete mode 100644 docs/docs/sidebar/development/agent-hardening.md diff --git a/docs/docs/sidebar/development/agent-hardening.md b/docs/docs/sidebar/development/agent-hardening.md deleted file mode 100644 index 70a9748c8..000000000 --- a/docs/docs/sidebar/development/agent-hardening.md +++ /dev/null @@ -1,380 +0,0 @@ -# Agent Hardening - -This guide covers running the OSAPI agent as an unprivileged user with minimal -permissions. By default the agent runs as root, but production deployments -should use a dedicated `osapi` user with sudo rules and Linux capabilities. - -## Overview - -The agent executes system commands via its exec manager. Some commands need root -privileges (user management, service control), while others work unprivileged -(package queries, reading logs). The hardening strategy: - -1. Run the agent as a dedicated `osapi` user -2. Enable privilege escalation in the config -3. Grant sudo access only for specific commands -4. Set Linux capabilities on the binary for direct file operations -5. The agent verifies its privileges at startup before accepting jobs - -## Configuration - -```yaml -agent: - privilege_escalation: - # Enable sudo for write operations. When true, the exec manager - # prepends "sudo" to commands that modify system state. Read - # operations are never elevated. - sudo: true - # Enable capability verification at startup. When true, the agent - # checks that the required Linux capabilities are set on the - # binary before accepting jobs. - capabilities: true - # Run preflight checks at startup. Verifies sudo access and - # capabilities are correctly configured. The agent refuses to - # start if checks fail. - preflight: true -``` - -When `privilege_escalation` is not set or all fields are false, the agent -behaves as before — commands run as the current user with no elevation. - -## Design: Exec Manager Changes - -The exec `Manager` interface gains a `RunPrivilegedCmd` method. Providers call -it for write operations and `RunCmd` for reads: - -```go -type Manager interface { - // RunCmd executes a command as the current user. Use for read - // operations that don't modify system state. - RunCmd( - name string, - args []string, - ) (string, error) - - // RunPrivilegedCmd executes a command with privilege escalation. - // When sudo is enabled in config, prepends "sudo" to the command. - // When sudo is disabled, behaves identically to RunCmd. - // Use for write operations that modify system state. - RunPrivilegedCmd( - name string, - args []string, - ) (string, error) - - // RunCmdFull executes a command with separate stdout/stderr - // capture, an optional working directory, and a timeout. - RunCmdFull( - name string, - args []string, - cwd string, - timeout int, - ) (*CmdResult, error) -} -``` - -The `Exec` struct gains a `sudo bool` field set from config: - -```go -type Exec struct { - logger *slog.Logger - sudo bool -} - -func New( - logger *slog.Logger, - sudo bool, -) *Exec { - return &Exec{ - logger: logger.With(slog.String("subsystem", "exec")), - sudo: sudo, - } -} - -func (e *Exec) RunPrivilegedCmd( - name string, - args []string, -) (string, error) { - if e.sudo { - args = append([]string{name}, args...) - name = "sudo" - } - return e.RunCmdImpl(name, args, "") -} -``` - -### Provider changes - -Each provider's write operations change from `RunCmd` to `RunPrivilegedCmd`. -Read operations stay on `RunCmd`. Example from the service provider: - -```go -// Read — no elevation -output, _ := d.execManager.RunCmd( - "systemctl", - []string{"is-active", unitName}, -) - -// Write — elevated when configured -_, err := d.execManager.RunPrivilegedCmd( - "systemctl", - []string{"start", unitName}, -) -``` - -The mock `Manager` interface gains `RunPrivilegedCmd`. Tests for write -operations expect `RunPrivilegedCmd`, tests for reads expect `RunCmd`. This -enforces the read/write distinction at the test level — using the wrong method -causes a mock expectation failure. - -## Design: Preflight Checks - -When `preflight: true`, the agent runs checks during `agent start` before -accepting jobs. If any check fails, the agent logs the failure and exits. - -### Sudo verification - -For each command in the sudo whitelist, run `sudo -n --help` (or -equivalent no-op) to verify the sudoers rule is configured. The `-n` flag -makes sudo fail immediately if a password would be required. - -```go -func (p *Preflight) CheckSudo( - commands []string, -) []PreflightResult { - // For each command, run "sudo -n --version" or - // similar no-op to verify access without side effects. -} -``` - -### Capability verification - -Read `/proc/self/status` and parse the `CapEff` line to check that required -capability bits are set: - -```go -func (p *Preflight) CheckCapabilities( - required []capability, -) []PreflightResult { - // Parse /proc/self/status CapEff bitmask - // Check each required capability bit -} -``` - -### Preflight 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 -``` - -## Create the Service Account - -```bash -sudo useradd --system --no-create-home --shell /usr/sbin/nologin osapi -``` - -## Sudoers Configuration - -Create `/etc/sudoers.d/osapi-agent` with the following content. This grants the -`osapi` user passwordless sudo access to exactly the commands the agent needs. - -```sudoers -# /etc/sudoers.d/osapi-agent -# OSAPI agent privilege escalation rules. -# Only the commands listed here can be run as root. - -# 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 * -``` - -Note: read-only commands (`systemctl list-units`, `sysctl -n`, `journalctl`, -etc.) are NOT in the sudoers file — they run unprivileged via `RunCmd`. - -Set permissions: - -```bash -sudo cp osapi-agent.sudoers /etc/sudoers.d/osapi-agent -sudo chmod 0440 /etc/sudoers.d/osapi-agent -sudo visudo -c # validate syntax -``` - -## Linux Capabilities - -Capabilities grant the agent binary specific privileges without full root -access. These cover direct file operations that don't go through external -commands (reads via `avfs`, writes to config directories). - -```bash -sudo setcap \ - 'cap_dac_read_search+ep cap_dac_override+ep cap_fowner+ep cap_kill+ep' \ - /usr/local/bin/osapi -``` - -| Capability | Purpose | -| --------------------- | ------------------------------------------------- | -| `cap_dac_read_search` | Read any file (e.g., `/etc/shadow` for user info) | -| `cap_dac_override` | Write files regardless of ownership (unit files, | -| | cron files, sysctl conf, CA certs) | -| `cap_fowner` | Change file ownership (SSH key directories) | -| `cap_kill` | Send signals to any process | - -Verify capabilities are set: - -```bash -getcap /usr/local/bin/osapi -# Expected: /usr/local/bin/osapi cap_dac_override,cap_dac_read_search,cap_fowner,cap_kill=ep -``` - -## Systemd Unit File - -Run the agent as the `osapi` user with ambient capabilities: - -```ini -# /etc/systemd/system/osapi-agent.service -[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 - -# Capabilities -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 - -# Hardening -NoNewPrivileges=no -ProtectSystem=false -ProtectHome=false -ReadWritePaths=/etc/systemd/system /etc/sysctl.d /etc/cron.d -ReadWritePaths=/usr/local/share/ca-certificates -PrivateTmp=true - -[Install] -WantedBy=multi-user.target -``` - -Note: `NoNewPrivileges=no` is required because the agent uses `sudo` for -privileged commands. If `NoNewPrivileges=yes` were set, `sudo` would be -blocked. - -## Command Privilege Reference - -Commands the agent executes, grouped by privilege requirement. This determines -whether a provider calls `RunCmd` (read) or `RunPrivilegedCmd` (write). - -### Write operations (use `RunPrivilegedCmd`) - -| Command | Domain | Operation | -| ------------------------- | ----------- | ----------------- | -| `systemctl start/stop/…` | Service | Lifecycle control | -| `systemctl daemon-reload` | Service | Unit file reload | -| `sysctl -p`, `--system` | Sysctl | Apply parameters | -| `timedatectl set-timezone` | Timezone | Set timezone | -| `hostnamectl set-hostname` | Hostname | Set hostname | -| `chronyc reload sources` | NTP | Apply NTP config | -| `useradd`, `usermod` | User | User management | -| `userdel -r` | User | Delete user | -| `groupadd`, `groupdel` | Group | Group management | -| `gpasswd -M` | Group | Set members | -| `chown -R` | SSH Key | Fix ownership | -| `apt-get install/remove` | Package | Install/remove | -| `apt-get update` | Package | Update index | -| `update-ca-certificates` | Certificate | Rebuild trust | -| `shutdown -r/-h` | Power | Reboot/shutdown | -| `sh -c "echo … chpasswd"` | User | Set password | - -### Read operations (use `RunCmd`) - -| Command | Domain | Operation | -| ---------------------------- | ------- | --------------- | -| `systemctl list-units` | Service | List services | -| `systemctl list-unit-files` | Service | List unit files | -| `systemctl show` | Service | Get service info| -| `systemctl is-active` | Service | Check status | -| `systemctl is-enabled` | Service | Check enabled | -| `sysctl -n` | Sysctl | Read parameter | -| `timedatectl show` | Timezone| Read timezone | -| `hostnamectl hostname` | Hostname| Read hostname | -| `journalctl` | Log | Query logs | -| `chronyc tracking` | NTP | Read NTP status | -| `chronyc sources -c` | NTP | List sources | -| `id -Gn` | User | Get user groups | -| `passwd -S` | User | Password status | -| `dpkg-query` | Package | Query packages | -| `apt list --upgradable` | Package | Check updates | -| `date +%:z` | Timezone| Read UTC offset | - -## Verification - -After setup, verify the agent can operate: - -```bash -# Switch to the osapi user and test -sudo -u osapi osapi agent start --dry-run - -# Verify sudo works for allowed commands -sudo -u osapi sudo -n systemctl list-units --type=service --no-pager - -# Verify sudo is denied for non-whitelisted commands -sudo -u osapi sudo -n rm /etc/passwd # should be denied -``` From 17026a03fa1322b37ec4d79657673d4a1a3284d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 13:53:35 -0700 Subject: [PATCH 04/21] docs: add agent privilege escalation design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config-driven sudo escalation, RunPrivilegedCmd exec manager method, Linux capabilities, preflight verification at agent startup, sudoers drop-in, and full command read/write classification. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- ...04-02-agent-privilege-escalation-design.md | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 docs/plans/2026-04-02-agent-privilege-escalation-design.md diff --git a/docs/plans/2026-04-02-agent-privilege-escalation-design.md b/docs/plans/2026-04-02-agent-privilege-escalation-design.md new file mode 100644 index 000000000..f4467df04 --- /dev/null +++ b/docs/plans/2026-04-02-agent-privilege-escalation-design.md @@ -0,0 +1,281 @@ +# Agent Privilege Escalation Design + +Run the OSAPI agent as an unprivileged user with config-driven sudo escalation +for write operations, Linux capabilities for direct file access, and preflight +verification at startup. + +## Problem + +The agent runs as root by default. This grants full system access to a +network-facing process that accepts jobs from NATS. A compromised agent (or a +malicious job) has unrestricted access to the host. The guiding principles call +for least-privilege mode. + +## Solution + +Split command execution into read and write paths. Reads run unprivileged. +Writes run through `sudo` when configured. The agent verifies its privileges at +startup and refuses to start if the configuration doesn't match the system +state. + +## Config + +```yaml +agent: + privilege_escalation: + sudo: true + capabilities: true + preflight: true +``` + +| Field | Type | Default | Description | +| -------------- | ---- | ------- | ---------------------------------------- | +| `sudo` | bool | false | Prepend `sudo` to write commands | +| `capabilities` | bool | false | Verify Linux capabilities at startup | +| `preflight` | bool | false | Run privilege checks before accepting jobs| + +When all fields are false (or the section is absent), the agent behaves as +before — commands run as the current user. + +## Exec Manager Interface + +Add `RunPrivilegedCmd` to the `Manager` interface: + +```go +type Manager interface { + RunCmd(name string, args []string) (string, error) + RunPrivilegedCmd(name string, args []string) (string, error) + RunCmdFull(name string, args []string, cwd string, timeout int) (*CmdResult, error) +} +``` + +The `Exec` struct gains a `sudo bool` field: + +```go +func (e *Exec) RunPrivilegedCmd( + name string, + args []string, +) (string, error) { + if e.sudo { + args = append([]string{name}, args...) + name = "sudo" + } + return e.RunCmdImpl(name, args, "") +} +``` + +When `sudo` is false, `RunPrivilegedCmd` is identical to `RunCmd`. + +## Provider Changes + +Every provider write operation changes from `RunCmd` to `RunPrivilegedCmd`. +Read operations stay on `RunCmd`. The providers themselves don't know or care +whether sudo is enabled — the exec manager handles it. + +```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}) +``` + +Tests enforce this: the mock `Manager` has both methods. If a write operation +calls `RunCmd` instead of `RunPrivilegedCmd`, the mock expectation fails. + +## Command Classification + +### 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 | + +## Preflight Checks + +Run during `agent start` before the agent subscribes 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. + +### Sudo verification + +For each write command, run `sudo -n --version` (or equivalent no-op +flag). The `-n` flag makes sudo fail immediately if a password would be +required. If the command doesn't support `--version`, use `sudo -n which +` as a fallback. + +### Capability verification + +Read `/proc/self/status`, parse the `CapEff` hexadecimal bitmask, and check +that required capability bits are set: + +| 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 | + +### Output format + +``` +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 +``` + +## Deployment Artifacts + +### Sudoers drop-in (`/etc/sudoers.d/osapi-agent`) + +```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 * +``` + +### Linux capabilities + +```bash +sudo setcap \ + 'cap_dac_read_search+ep cap_dac_override+ep cap_fowner+ep cap_kill+ep' \ + /usr/local/bin/osapi +``` + +### Systemd unit file + +```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 +``` + +## Not Changing + +- Controller and NATS server — already run unprivileged, no changes needed +- The `command exec` and `command shell` endpoints — these execute arbitrary + user-provided commands, so they inherit whatever privileges the agent has. + They are gated by the `command:execute` permission in RBAC. +- Docker provider — talks to the Docker API socket, not system commands. The + `osapi` user needs to be in the `docker` group. + +## Files Changed + +- `internal/config/types.go` — add `PrivilegeEscalation` struct +- `internal/exec/manager.go` — add `RunPrivilegedCmd` to interface +- `internal/exec/types.go` — add `sudo bool` to `Exec` struct +- `internal/exec/run_privileged_cmd.go` — new file, implementation +- `internal/exec/run_privileged_cmd_public_test.go` — tests +- `internal/exec/mocks/` — regenerate +- `internal/agent/preflight.go` — new file, sudo + caps verification +- `internal/agent/preflight_public_test.go` — tests +- `internal/agent/agent.go` — call preflight during `Start()` +- `cmd/agent_setup.go` — pass `sudo` bool to exec manager +- Every provider `debian*.go` file — change write `RunCmd` to + `RunPrivilegedCmd` (~37 call sites) +- Every provider `debian*_public_test.go` — update mock expectations +- `docs/docs/sidebar/usage/configuration.md` — add config reference +- `docs/docs/sidebar/features/` — add agent hardening feature page From a9bb59f52405d7b9664f3316a0537f81831ff192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 13:58:33 -0700 Subject: [PATCH 05/21] docs: add agent privilege escalation implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 15 tasks covering exec manager RunPrivilegedCmd, preflight checks, provider migration across 10 domains, and documentation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../2026-04-02-agent-privilege-escalation.md | 958 ++++++++++++++++++ 1 file changed, 958 insertions(+) create mode 100644 docs/plans/2026-04-02-agent-privilege-escalation.md diff --git a/docs/plans/2026-04-02-agent-privilege-escalation.md b/docs/plans/2026-04-02-agent-privilege-escalation.md new file mode 100644 index 000000000..cfe81c67b --- /dev/null +++ b/docs/plans/2026-04-02-agent-privilege-escalation.md @@ -0,0 +1,958 @@ +# Agent Privilege Escalation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add config-driven sudo escalation for write commands, Linux +capability verification, and startup preflight checks to the OSAPI +agent so it can run as an unprivileged user. + +**Architecture:** The exec `Manager` interface gains `RunPrivilegedCmd` +which prepends `sudo` when configured. Providers call it for write +operations. At startup, the agent runs preflight checks to verify +sudo and capabilities before accepting jobs. + +**Tech Stack:** Go, Linux capabilities (`/proc/self/status`), sudo + +--- + +### Task 1: Add privilege escalation config + +**Files:** +- Modify: `internal/config/types.go:353-374` +- Modify: `configs/osapi.yaml` +- Modify: `configs/osapi.nerd.yaml` +- Modify: `configs/osapi.local.yaml` + +- [ ] **Step 1: Add PrivilegeEscalation struct and wire into AgentConfig** + +In `internal/config/types.go`, add a new struct before `AgentConfig`: + +```go +// PrivilegeEscalation configuration for least-privilege agent mode. +type PrivilegeEscalation struct { + // Sudo prepends "sudo" to write commands when true. + Sudo bool `mapstructure:"sudo"` + // Capabilities verifies Linux capabilities at startup when true. + Capabilities bool `mapstructure:"capabilities"` + // Preflight runs privilege checks before accepting jobs when true. + Preflight bool `mapstructure:"preflight"` +} +``` + +Add the field to `AgentConfig`: + +```go +type AgentConfig struct { + // ... existing fields ... + // PrivilegeEscalation configures least-privilege agent mode. + PrivilegeEscalation PrivilegeEscalation `mapstructure:"privilege_escalation,omitempty"` +} +``` + +- [ ] **Step 2: Add config to YAML files** + +In `configs/osapi.yaml`, `configs/osapi.nerd.yaml`, and +`configs/osapi.local.yaml`, add to the `agent:` section (disabled by +default): + +```yaml + # Least-privilege mode. When enabled, the agent runs as an + # unprivileged user and uses sudo for write operations. + # privilege_escalation: + # sudo: false + # capabilities: false + # preflight: false +``` + +- [ ] **Step 3: Verify build** + +Run: `go build ./...` + +- [ ] **Step 4: Commit** + +``` +feat(agent): add privilege escalation config +``` + +--- + +### Task 2: Add RunPrivilegedCmd to exec manager + +**Files:** +- Modify: `internal/exec/manager.go` +- Modify: `internal/exec/types.go` +- Modify: `internal/exec/exec.go` +- Create: `internal/exec/run_privileged_cmd.go` +- Create: `internal/exec/run_privileged_cmd_public_test.go` +- Modify: `internal/exec/mocks/generate.go` + +- [ ] **Step 1: Add RunPrivilegedCmd to the Manager interface** + +In `internal/exec/manager.go`: + +```go +type Manager interface { + // RunCmd executes the provided command with arguments, using the + // current working directory. Use for read operations. + RunCmd( + name string, + args []string, + ) (string, error) + + // RunPrivilegedCmd executes a command with privilege escalation. + // When sudo is enabled in config, prepends "sudo" to the command. + // When sudo is disabled, behaves identically to RunCmd. + // Use for write operations that modify system state. + RunPrivilegedCmd( + name string, + args []string, + ) (string, error) + + // RunCmdFull executes a command with separate stdout/stderr capture, + // an optional working directory, and a timeout in seconds. + RunCmdFull( + name string, + args []string, + cwd string, + timeout int, + ) (*CmdResult, error) +} +``` + +- [ ] **Step 2: Add sudo field to Exec struct and update constructor** + +In `internal/exec/types.go`, add the `sudo` field: + +```go +type Exec struct { + logger *slog.Logger + sudo bool +} +``` + +In `internal/exec/exec.go`, update the constructor: + +```go +func New( + logger *slog.Logger, + sudo bool, +) *Exec { + return &Exec{ + logger: logger.With(slog.String("subsystem", "exec")), + sudo: sudo, + } +} +``` + +- [ ] **Step 3: Create RunPrivilegedCmd implementation** + +Create `internal/exec/run_privileged_cmd.go`: + +```go +package exec + +// RunPrivilegedCmd executes a command with privilege escalation. +// When sudo is enabled, the command is run via "sudo". When disabled, +// it behaves identically to RunCmd. +func (e *Exec) RunPrivilegedCmd( + name string, + args []string, +) (string, error) { + if e.sudo { + args = append([]string{name}, args...) + name = "sudo" + } + + return e.RunCmdImpl(name, args, "") +} +``` + +- [ ] **Step 4: Write tests** + +Create `internal/exec/run_privileged_cmd_public_test.go`. Test: + +- When sudo is false, RunPrivilegedCmd behaves like RunCmd (runs + the command directly) +- When sudo is true, the command is prefixed with sudo (verify + the actual args passed to the underlying exec) + +Since `RunCmdImpl` calls `os/exec.Command`, use a test helper +pattern: override the command execution to capture what would be +run. Look at how `run_cmd_public_test.go` tests `RunCmd` and follow +the same pattern. + +- [ ] **Step 5: Update agent_setup.go to pass sudo config** + +In `cmd/agent_setup.go`, change the `exec.New` call: + +```go +// Before: +execManager := exec.New(log) + +// After: +execManager := exec.New( + log, + appConfig.Agent.PrivilegeEscalation.Sudo, +) +``` + +- [ ] **Step 6: Regenerate mocks** + +Run: `go generate ./internal/exec/mocks/...` + +- [ ] **Step 7: Fix compilation errors** + +The mock `Manager` now requires `RunPrivilegedCmd`. Any existing test +that constructs a mock `Manager` may need updating. Run +`go build ./...` and fix any compile errors. + +- [ ] **Step 8: Run tests** + +Run: `go test ./internal/exec/... -count=1` +Run: `go build ./...` + +- [ ] **Step 9: Commit** + +``` +feat(exec): add RunPrivilegedCmd with config-driven sudo +``` + +--- + +### Task 3: Add preflight checks + +**Files:** +- Create: `internal/agent/preflight.go` +- Create: `internal/agent/preflight_public_test.go` +- Modify: `internal/agent/server.go` + +- [ ] **Step 1: Create preflight types and runner** + +Create `internal/agent/preflight.go`: + +```go +package agent + +import ( + "bufio" + "encoding/hex" + "fmt" + "log/slog" + "os" + "strings" + + "github.com/retr0h/osapi/internal/exec" +) + +// PreflightResult holds the outcome of a single preflight check. +type PreflightResult struct { + Name string + Passed bool + Error string +} + +// sudoCommands lists the binaries that require sudo access. +var sudoCommands = []string{ + "systemctl", + "sysctl", + "timedatectl", + "hostnamectl", + "chronyc", + "useradd", + "usermod", + "userdel", + "groupadd", + "groupdel", + "gpasswd", + "chown", + "apt-get", + "shutdown", + "update-ca-certificates", + "sh", +} + +// requiredCapabilities maps capability names to their bit positions +// in the CapEff bitmask from /proc/self/status. +var requiredCapabilities = map[string]uint{ + "CAP_DAC_OVERRIDE": 1, + "CAP_DAC_READ_SEARCH": 2, + "CAP_FOWNER": 3, + "CAP_KILL": 5, +} + +// procStatusPath is the path to read for capability detection. +// Overridden in tests. +var procStatusPath = "/proc/self/status" + +// RunPreflight checks sudo access and capabilities. Returns all +// results and whether all checks passed. +func RunPreflight( + logger *slog.Logger, + execManager exec.Manager, + checkSudo bool, + checkCaps bool, +) ([]PreflightResult, bool) { + var results []PreflightResult + allPassed := true + + if checkSudo { + sudoResults := checkSudoAccess(logger, execManager) + results = append(results, sudoResults...) + for _, r := range sudoResults { + if !r.Passed { + allPassed = false + } + } + } + + if checkCaps { + capResults := checkCapabilities(logger) + results = append(results, capResults...) + for _, r := range capResults { + if !r.Passed { + allPassed = false + } + } + } + + return results, allPassed +} + +// checkSudoAccess verifies that sudo -n works for each required +// command. Uses "sudo -n which " which is a no-op that +// tests sudo access without side effects. +func checkSudoAccess( + logger *slog.Logger, + execManager exec.Manager, +) []PreflightResult { + results := make([]PreflightResult, 0, len(sudoCommands)) + + for _, cmd := range sudoCommands { + _, err := execManager.RunCmd( + "sudo", + []string{"-n", "which", cmd}, + ) + + result := PreflightResult{ + Name: "sudo:" + cmd, + Passed: err == nil, + } + if err != nil { + result.Error = fmt.Sprintf( + "sudo access denied for %s: %s", + cmd, + err.Error(), + ) + } + + logger.Debug( + "preflight sudo check", + slog.String("command", cmd), + slog.Bool("passed", result.Passed), + ) + + results = append(results, result) + } + + return results +} + +// checkCapabilities reads /proc/self/status and verifies that +// required capability bits are set in the effective capability mask. +func checkCapabilities( + logger *slog.Logger, +) []PreflightResult { + capEff, err := readCapEff() + if err != nil { + return []PreflightResult{{ + Name: "capabilities", + Passed: false, + Error: fmt.Sprintf("failed to read capabilities: %s", err), + }} + } + + results := make([]PreflightResult, 0, len(requiredCapabilities)) + + for name, bit := range requiredCapabilities { + hasCap := (capEff>>bit)&1 == 1 + result := PreflightResult{ + Name: "cap:" + name, + Passed: hasCap, + } + if !hasCap { + result.Error = fmt.Sprintf("%s not set", name) + } + + logger.Debug( + "preflight capability check", + slog.String("capability", name), + slog.Bool("passed", hasCap), + ) + + results = append(results, result) + } + + return results +} + +// readCapEff reads the effective capability bitmask from +// /proc/self/status. +func readCapEff() (uint64, error) { + f, err := os.Open(procStatusPath) + if err != nil { + return 0, fmt.Errorf("open %s: %w", procStatusPath, err) + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "CapEff:") { + hexStr := strings.TrimSpace( + strings.TrimPrefix(line, "CapEff:"), + ) + bytes, err := hex.DecodeString(hexStr) + if err != nil { + return 0, fmt.Errorf( + "decode CapEff %q: %w", + hexStr, + err, + ) + } + // Convert big-endian bytes to uint64. + var val uint64 + for _, b := range bytes { + val = (val << 8) | uint64(b) + } + return val, nil + } + } + + return 0, fmt.Errorf("CapEff not found in %s", procStatusPath) +} +``` + +- [ ] **Step 2: Write preflight tests** + +Create `internal/agent/preflight_public_test.go`. Use testify/suite +with table-driven tests. Test: + +**TestCheckSudoAccess:** +- All commands pass (mock RunCmd returns nil for all sudo -n which calls) +- One command fails (mock RunCmd returns error for one) +- Multiple commands fail + +**TestCheckCapabilities:** +- All capabilities present (write a fake /proc/self/status file with + full CapEff, override `procStatusPath` via export_test.go) +- Missing capability (write CapEff without a required bit) +- Cannot read file (set procStatusPath to nonexistent path) + +**TestRunPreflight:** +- Both sudo and caps enabled and pass → allPassed true +- Sudo fails → allPassed false +- Caps fails → allPassed false +- Both disabled → empty results, allPassed true + +Create `internal/agent/export_test.go` (or add to existing) to +expose `procStatusPath` for testing: + +```go +package agent + +func SetProcStatusPath(p string) { procStatusPath = p } +func ResetProcStatusPath() { procStatusPath = "/proc/self/status" } +``` + +- [ ] **Step 3: Wire preflight into agent Start()** + +In `internal/agent/server.go`, add preflight check after hostname +determination but before starting heartbeat: + +```go +func (a *Agent) Start() { + a.ctx, a.cancel = context.WithCancel(context.Background()) + a.startedAt = time.Now() + a.state = job.AgentStateReady + + a.logger.Info("starting node agent") + + a.hostname, _ = job.GetAgentHostname(a.appConfig.Agent.Hostname) + + // Run preflight checks if configured. + pe := a.appConfig.Agent.PrivilegeEscalation + if pe.Preflight { + results, ok := RunPreflight( + a.logger, + a.execManager, + pe.Sudo, + pe.Capabilities, + ) + if !ok { + for _, r := range results { + if !r.Passed { + a.logger.Error( + "preflight check failed", + slog.String("check", r.Name), + slog.String("error", r.Error), + ) + } + } + a.logger.Error("preflight failed, agent cannot start") + a.cancel() + return + } + a.logger.Info("preflight checks passed") + } + + // ... rest of Start() unchanged ... +``` + +The agent needs access to the exec manager. Check if it's already on +the `Agent` struct. If not, add an `execManager exec.Manager` field +and pass it from `agent_setup.go`. + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/agent/... -count=1` +Run: `go build ./...` + +- [ ] **Step 5: Commit** + +``` +feat(agent): add preflight checks for sudo and capabilities +``` + +--- + +### Task 4: Migrate service provider to RunPrivilegedCmd + +**Files:** +- Modify: `internal/provider/node/service/debian_action.go` +- Modify: `internal/provider/node/service/debian_unit.go` +- Modify: `internal/provider/node/service/debian_action_public_test.go` +- Modify: `internal/provider/node/service/debian_unit_public_test.go` + +- [ ] **Step 1: Update write calls in debian_action.go** + +Change these `RunCmd` calls to `RunPrivilegedCmd`: + +```go +// Start — line 49: "systemctl start" is a write +d.execManager.RunPrivilegedCmd("systemctl", []string{"start", unitName}) + +// Stop — "systemctl stop" is a write +d.execManager.RunPrivilegedCmd("systemctl", []string{"stop", unitName}) + +// Restart — "systemctl restart" is a write +d.execManager.RunPrivilegedCmd("systemctl", []string{"restart", unitName}) + +// Enable — "systemctl enable" is a write +d.execManager.RunPrivilegedCmd("systemctl", []string{"enable", unitName}) + +// Disable — "systemctl disable" is a write +d.execManager.RunPrivilegedCmd("systemctl", []string{"disable", unitName}) +``` + +Keep these as `RunCmd` (reads): +- `systemctl is-active` (Start, Stop) +- `systemctl is-enabled` (Enable, Disable) + +- [ ] **Step 2: Update write calls in debian_unit.go** + +```go +// Delete — "systemctl stop" and "systemctl disable" are writes +d.execManager.RunPrivilegedCmd("systemctl", []string{"stop", unitName}) +d.execManager.RunPrivilegedCmd("systemctl", []string{"disable", unitName}) + +// daemonReload — "systemctl daemon-reload" is a write +d.execManager.RunPrivilegedCmd("systemctl", []string{"daemon-reload"}) +``` + +- [ ] **Step 3: Update test mock expectations** + +In `debian_action_public_test.go`, change all mock expectations for +write commands from `RunCmd` to `RunPrivilegedCmd`. Keep read command +expectations on `RunCmd`. + +In `debian_unit_public_test.go`, change mock expectations for +`systemctl stop`, `systemctl disable`, `systemctl daemon-reload` +from `RunCmd` to `RunPrivilegedCmd`. + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/provider/node/service/... -count=1` + +- [ ] **Step 5: Commit** + +``` +refactor(service): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 5: Migrate sysctl provider + +**Files:** +- Modify: `internal/provider/node/sysctl/debian.go` +- Modify: `internal/provider/node/sysctl/debian_public_test.go` + +- [ ] **Step 1: Update write calls** + +Change to `RunPrivilegedCmd`: +- `sysctl -p ` (apply config) +- `sysctl --system` (reload all) + +Keep as `RunCmd`: +- `sysctl -n ` (read parameter) + +- [ ] **Step 2: Update test mock expectations** + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/provider/node/sysctl/... -count=1` + +- [ ] **Step 4: Commit** + +``` +refactor(sysctl): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 6: Migrate hostname provider + +**Files:** +- Modify: `internal/provider/node/host/debian_update_hostname.go` +- Modify: `internal/provider/node/host/debian_update_hostname_public_test.go` + +- [ ] **Step 1: Update write calls** + +Change to `RunPrivilegedCmd`: +- `hostnamectl set-hostname ` + +Keep as `RunCmd` (in other host files): +- `hostnamectl hostname` (read) + +- [ ] **Step 2: Update test mock expectations** + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/provider/node/host/... -count=1` + +- [ ] **Step 4: Commit** + +``` +refactor(host): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 7: Migrate timezone provider + +**Files:** +- Modify: `internal/provider/node/timezone/debian.go` +- Modify: `internal/provider/node/timezone/debian_public_test.go` + +- [ ] **Step 1: Update write calls** + +Change to `RunPrivilegedCmd`: +- `timedatectl set-timezone ` + +Keep as `RunCmd`: +- `timedatectl show -p Timezone --value` (read) +- `date +%:z` (read) + +- [ ] **Step 2: Update test mock expectations** + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/provider/node/timezone/... -count=1` + +- [ ] **Step 4: Commit** + +``` +refactor(timezone): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 8: Migrate NTP provider + +**Files:** +- Modify: `internal/provider/node/ntp/debian.go` +- Modify: `internal/provider/node/ntp/debian_public_test.go` + +- [ ] **Step 1: Update write calls** + +Change to `RunPrivilegedCmd`: +- `chronyc reload sources` + +Keep as `RunCmd`: +- `chronyc tracking` (read) +- `chronyc sources -c` (read) + +- [ ] **Step 2: Update test mock expectations** + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/provider/node/ntp/... -count=1` + +- [ ] **Step 4: Commit** + +``` +refactor(ntp): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 9: Migrate user provider + +**Files:** +- Modify: `internal/provider/node/user/debian_user.go` +- Modify: `internal/provider/node/user/debian_group.go` +- Modify: `internal/provider/node/user/debian_ssh_key.go` +- Modify: `internal/provider/node/user/debian_user_public_test.go` +- Modify: `internal/provider/node/user/debian_group_public_test.go` +- Modify: `internal/provider/node/user/debian_ssh_key_public_test.go` + +- [ ] **Step 1: Update write calls in debian_user.go** + +Change to `RunPrivilegedCmd`: +- `useradd --create-home ...` +- `usermod ...` (all variants) +- `userdel -r ...` +- `sh -c "echo ... | chpasswd"` + +Keep as `RunCmd`: +- `id -Gn ` (read) +- `passwd -S ` (read) + +- [ ] **Step 2: Update write calls in debian_group.go** + +Change to `RunPrivilegedCmd`: +- `groupadd ...` +- `groupdel ...` +- `gpasswd -M ...` + +- [ ] **Step 3: Update write calls in debian_ssh_key.go** + +Change to `RunPrivilegedCmd`: +- `chown -R ...` + +- [ ] **Step 4: Update all test mock expectations** + +- [ ] **Step 5: Run tests** + +Run: `go test ./internal/provider/node/user/... -count=1` + +- [ ] **Step 6: Commit** + +``` +refactor(user): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 10: Migrate package provider + +**Files:** +- Modify: `internal/provider/node/apt/debian.go` +- Modify: `internal/provider/node/apt/debian_public_test.go` + +- [ ] **Step 1: Update write calls** + +Change to `RunPrivilegedCmd`: +- `apt-get install -y ...` +- `apt-get remove -y ...` +- `apt-get update` + +Keep as `RunCmd`: +- `dpkg-query ...` (read) +- `apt list --upgradable` (read) + +- [ ] **Step 2: Update test mock expectations** + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/provider/node/apt/... -count=1` + +- [ ] **Step 4: Commit** + +``` +refactor(apt): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 11: Migrate power provider + +**Files:** +- Modify: `internal/provider/node/power/debian.go` +- Modify: `internal/provider/node/power/debian_public_test.go` + +- [ ] **Step 1: Update write calls** + +Change to `RunPrivilegedCmd`: +- `shutdown -r ...` (reboot) +- `shutdown -h ...` (halt) + +- [ ] **Step 2: Update test mock expectations** + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/provider/node/power/... -count=1` + +- [ ] **Step 4: Commit** + +``` +refactor(power): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 12: Migrate certificate provider + +**Files:** +- Modify: `internal/provider/node/certificate/debian.go` +- Modify: `internal/provider/node/certificate/debian_public_test.go` + +- [ ] **Step 1: Update write calls** + +Change to `RunPrivilegedCmd`: +- `update-ca-certificates` + +- [ ] **Step 2: Update test mock expectations** + +- [ ] **Step 3: Run tests** + +Run: `go test ./internal/provider/node/certificate/... -count=1` + +- [ ] **Step 4: Commit** + +``` +refactor(certificate): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 13: Migrate DNS provider + +**Files:** +- Modify: `internal/provider/network/dns/debian_update_resolv_conf_by_interface.go` + (or equivalent write file) +- Modify: corresponding test file + +- [ ] **Step 1: Check if DNS uses exec for writes** + +Read the DNS provider to determine if it executes commands for +writes. The DNS provider may use `resolvectl` or write files +directly. If it uses `RunCmd` for writes, change to +`RunPrivilegedCmd`. If it only writes files via `avfs`, no changes +needed (file writes are covered by capabilities). + +- [ ] **Step 2: Update if needed and run tests** + +Run: `go test ./internal/provider/network/dns/... -count=1` + +- [ ] **Step 3: Commit (if changes made)** + +``` +refactor(dns): use RunPrivilegedCmd for write operations +``` + +--- + +### Task 14: Update documentation + +**Files:** +- Modify: `docs/docs/sidebar/usage/configuration.md` +- Create: `docs/docs/sidebar/features/agent-hardening.md` + +- [ ] **Step 1: Add config reference to configuration.md** + +In the agent section of configuration.md, add: + +```yaml + # Least-privilege mode for running the agent as an unprivileged user. + privilege_escalation: + # Prepend "sudo" to write commands. + sudo: false + # Verify Linux capabilities at startup. + capabilities: false + # Run privilege checks before accepting jobs. + preflight: false +``` + +Add the environment variable mappings: + +| Config Key | Environment Variable | +|---|---| +| `agent.privilege_escalation.sudo` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_SUDO` | +| `agent.privilege_escalation.capabilities` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_CAPABILITIES` | +| `agent.privilege_escalation.preflight` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_PREFLIGHT` | + +- [ ] **Step 2: Create agent hardening feature page** + +Create `docs/docs/sidebar/features/agent-hardening.md` with: +- Overview of least-privilege mode +- Configuration reference +- Sudoers drop-in (copy from spec) +- Capabilities setup (copy from spec) +- Systemd unit file (copy from spec) +- Preflight output example +- Command privilege reference tables (copy from spec) + +- [ ] **Step 3: Update docusaurus.config.ts** + +Add "Agent Hardening" to the Features navbar dropdown. + +- [ ] **Step 4: Commit** + +``` +docs: add agent hardening feature page and config reference +``` + +--- + +### Task 15: Final verification + +- [ ] **Step 1: Run full test suite** + +```bash +just go::unit +``` + +All tests must pass. + +- [ ] **Step 2: Build and lint** + +```bash +go build ./... +just go::vet +``` + +- [ ] **Step 3: Verify no stray RunCmd calls for write operations** + +Check each write command from the spec against the codebase to +confirm it uses `RunPrivilegedCmd`: + +```bash +grep -rn 'RunCmd.*"systemctl".*"start\|stop\|restart\|enable\|disable\|daemon-reload"' \ + internal/provider/ --include="*.go" | grep -v _test.go | grep -v RunPrivilegedCmd +``` + +Repeat for other write commands (`useradd`, `usermod`, `apt-get`, +`shutdown`, `sysctl -p`, etc.). Expect: no matches. + +- [ ] **Step 4: Verify read commands stayed on RunCmd** + +```bash +grep -rn 'RunPrivilegedCmd.*"systemctl".*"list-units\|list-unit-files\|show\|is-active\|is-enabled"' \ + internal/provider/ --include="*.go" | grep -v _test.go +``` + +Expect: no matches (reads should not use RunPrivilegedCmd). From 59077ce0110090c9e9bd8abbdc896fe936c14f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 14:14:43 -0700 Subject: [PATCH 06/21] feat(agent): add privilege escalation config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PrivilegeEscalation struct to config types with sudo, capabilities, and preflight fields. Wire into AgentConfig and add commented-out defaults to all config files. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- configs/osapi.yaml | 6 ++++++ internal/config/types.go | 14 +++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/configs/osapi.yaml b/configs/osapi.yaml index bfac9d191..c299e29e2 100644 --- a/configs/osapi.yaml +++ b/configs/osapi.yaml @@ -171,4 +171,10 @@ agent: enabled: true host: '0.0.0.0' port: 9091 + # Least-privilege mode. When enabled, the agent runs as an + # unprivileged user and uses sudo for write operations. + # privilege_escalation: + # sudo: false + # capabilities: false + # preflight: false diff --git a/internal/config/types.go b/internal/config/types.go index 654ecfd00..f49bec6bd 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -350,6 +350,16 @@ type ProcessConditions struct { HighCPUPercent float64 `mapstructure:"high_cpu_percent"` } +// PrivilegeEscalation configuration for least-privilege agent mode. +type PrivilegeEscalation struct { + // Sudo prepends "sudo" to write commands when true. + Sudo bool `mapstructure:"sudo"` + // Capabilities verifies Linux capabilities at startup when true. + Capabilities bool `mapstructure:"capabilities"` + // Preflight runs privilege checks before accepting jobs when true. + Preflight bool `mapstructure:"preflight"` +} + // AgentConfig configuration settings. type AgentConfig struct { // NATS connection settings for the agent. @@ -370,5 +380,7 @@ type AgentConfig struct { Conditions AgentConditions `mapstructure:"conditions,omitempty"` // ProcessConditions holds threshold settings for process-level condition evaluation. ProcessConditions ProcessConditions `mapstructure:"process_conditions,omitempty"` - Metrics MetricsServer `mapstructure:"metrics"` + // PrivilegeEscalation configures least-privilege agent mode. + PrivilegeEscalation PrivilegeEscalation `mapstructure:"privilege_escalation,omitempty"` + Metrics MetricsServer `mapstructure:"metrics"` } From 6cc7ac278dc32c5454cf41c34caac945217f9721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 14:25:33 -0700 Subject: [PATCH 07/21] feat(agent): add preflight checks for sudo and capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a preflight system that runs during agent start to verify sudo access and Linux capabilities before accepting jobs. Checks are controlled by the privilege_escalation config (sudo, capabilities, preflight flags). Also adds execManager field to Agent struct for preflight sudo verification. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/agent_setup.go | 3 +- internal/agent/agent.go | 3 + internal/agent/export_test.go | 27 +++ internal/agent/fixture_public_test.go | 1 + internal/agent/preflight.go | 222 +++++++++++++++++++ internal/agent/preflight_public_test.go | 274 ++++++++++++++++++++++++ internal/agent/server.go | 23 ++ internal/agent/types.go | 12 +- 8 files changed, 559 insertions(+), 6 deletions(-) create mode 100644 internal/agent/preflight.go create mode 100644 internal/agent/preflight_public_test.go diff --git a/cmd/agent_setup.go b/cmd/agent_setup.go index 304825d8c..4ca81dca8 100644 --- a/cmd/agent_setup.go +++ b/cmd/agent_setup.go @@ -82,7 +82,7 @@ func setupAgent( log.Info("running on darwin") } - execManager := exec.New(log) + execManager := exec.New(log, appConfig.Agent.PrivilegeEscalation.Sudo) // --- Node providers --- var hostProvider nodeHost.Provider @@ -303,6 +303,7 @@ func setupAgent( registry, b.registryKV, b.factsKV, + execManager, ) enabledOrDisabled := func(enabled bool) string { diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 333dcad81..78035c3de 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -31,6 +31,7 @@ import ( sdkmetric "go.opentelemetry.io/otel/sdk/metric" "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/exec" "github.com/retr0h/osapi/internal/job" "github.com/retr0h/osapi/internal/job/client" "github.com/retr0h/osapi/internal/provider" @@ -58,6 +59,7 @@ func New( registry *ProviderRegistry, registryKV jetstream.KeyValue, factsKV jetstream.KeyValue, + execManager exec.Manager, ) *Agent { logger = logger.With(slog.String("subsystem", "agent")) @@ -76,6 +78,7 @@ func New( registry: registry, registryKV: registryKV, factsKV: factsKV, + execManager: execManager, heartbeatLogger: logger.With(slog.String("subsystem", "heartbeat")), factsLogger: logger.With(slog.String("subsystem", "facts")), } diff --git a/internal/agent/export_test.go b/internal/agent/export_test.go index 4a78103a8..d6629ff19 100644 --- a/internal/agent/export_test.go +++ b/internal/agent/export_test.go @@ -24,11 +24,13 @@ import ( "context" "encoding/json" "io/fs" + "log/slog" "time" "github.com/nats-io/nats.go/jetstream" "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/exec" "github.com/retr0h/osapi/internal/job" "github.com/retr0h/osapi/internal/provider/command" dockerProv "github.com/retr0h/osapi/internal/provider/container/docker" @@ -332,6 +334,31 @@ func SetDockerNewFn(fn func() (*dockerProv.Client, error)) { // ResetDockerNewFn is a no-op kept for backward compat with test suites. func ResetDockerNewFn() {} +// SetProcStatusPath overrides the procStatusPath for testing. +func SetProcStatusPath(p string) { + procStatusPath = p +} + +// ResetProcStatusPath restores the default procStatusPath. +func ResetProcStatusPath() { + procStatusPath = "/proc/self/status" +} + +// ExportCheckSudoAccess exposes the private checkSudoAccess function for testing. +func ExportCheckSudoAccess( + logger *slog.Logger, + execManager exec.Manager, +) []PreflightResult { + return checkSudoAccess(logger, execManager) +} + +// ExportCheckCapabilities exposes the private checkCapabilities function for testing. +func ExportCheckCapabilities( + logger *slog.Logger, +) []PreflightResult { + return checkCapabilities(logger) +} + // --- Field accessors for Agent struct --- // GetAgentAppConfig returns the agent's appConfig field for testing. diff --git a/internal/agent/fixture_public_test.go b/internal/agent/fixture_public_test.go index a4b113e3b..53ba7af5c 100644 --- a/internal/agent/fixture_public_test.go +++ b/internal/agent/fixture_public_test.go @@ -152,5 +152,6 @@ func newTestAgent(p newTestAgentParams) *agent.Agent { registry, p.registryKV, p.factsKV, + nil, ) } diff --git a/internal/agent/preflight.go b/internal/agent/preflight.go new file mode 100644 index 000000000..a504f30d2 --- /dev/null +++ b/internal/agent/preflight.go @@ -0,0 +1,222 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package agent + +import ( + "bufio" + "fmt" + "log/slog" + "os" + "strconv" + "strings" + + "github.com/retr0h/osapi/internal/exec" +) + +// PreflightResult holds the outcome of a single preflight check. +type PreflightResult struct { + Name string + Passed bool + Error string +} + +// sudoCommands lists binaries that require sudo access for agent operations. +var sudoCommands = []string{ + "systemctl", + "sysctl", + "timedatectl", + "hostnamectl", + "chronyc", + "useradd", + "usermod", + "userdel", + "groupadd", + "groupdel", + "gpasswd", + "chown", + "apt-get", + "shutdown", + "update-ca-certificates", + "sh", +} + +// requiredCapabilities maps Linux capability names to their bit positions +// in the CapEff bitmask. +var requiredCapabilities = map[string]int{ + "CAP_DAC_OVERRIDE": 1, + "CAP_DAC_READ_SEARCH": 2, + "CAP_FOWNER": 3, + "CAP_KILL": 5, +} + +// procStatusPath is the path to the proc status file for reading capabilities. +// Overridable in tests via export_test.go. +var procStatusPath = "/proc/self/status" + +// RunPreflight runs sudo and capability checks based on the provided flags. +// Returns the combined results and whether all checks passed. +func RunPreflight( + logger *slog.Logger, + execManager exec.Manager, + checkSudo bool, + checkCaps bool, +) ([]PreflightResult, bool) { + var results []PreflightResult + allPassed := true + + if checkSudo { + sudoResults := checkSudoAccess(logger, execManager) + results = append(results, sudoResults...) + + for _, r := range sudoResults { + if !r.Passed { + allPassed = false + } + } + } + + if checkCaps { + capResults := checkCapabilities(logger) + results = append(results, capResults...) + + for _, r := range capResults { + if !r.Passed { + allPassed = false + } + } + } + + return results, allPassed +} + +// checkSudoAccess verifies that the agent can run each required command +// via sudo without a password prompt. +func checkSudoAccess( + logger *slog.Logger, + execManager exec.Manager, +) []PreflightResult { + var results []PreflightResult + + for _, cmd := range sudoCommands { + name := fmt.Sprintf("sudo:%s", cmd) + + _, err := execManager.RunCmd("sudo", []string{"-n", "which", cmd}) + if err != nil { + logger.Debug("sudo preflight check failed", + slog.String("command", cmd), + slog.String("error", err.Error()), + ) + results = append(results, PreflightResult{ + Name: name, + Passed: false, + Error: fmt.Sprintf("sudo -n which %s: %s", cmd, err.Error()), + }) + + continue + } + + results = append(results, PreflightResult{ + Name: name, + Passed: true, + }) + } + + return results +} + +// checkCapabilities verifies that the agent process has the required Linux +// capabilities in its effective capability set. +func checkCapabilities( + logger *slog.Logger, +) []PreflightResult { + var results []PreflightResult + + capEff, err := readCapEff() + if err != nil { + logger.Debug("capability preflight check failed", + slog.String("error", err.Error()), + ) + + for capName := range requiredCapabilities { + results = append(results, PreflightResult{ + Name: fmt.Sprintf("cap:%s", capName), + Passed: false, + Error: fmt.Sprintf("failed to read capabilities: %s", err.Error()), + }) + } + + return results + } + + for capName, bit := range requiredCapabilities { + name := fmt.Sprintf("cap:%s", capName) + + if capEff&(1< Date: Thu, 2 Apr 2026 14:29:11 -0700 Subject: [PATCH 08/21] refactor(service): use RunPrivilegedCmd for write operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate all systemctl write commands (start, stop, restart, enable, disable, daemon-reload) to RunPrivilegedCmd so they run with privilege escalation. Read-only checks (is-active, is-enabled) remain on RunCmd. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../provider/node/service/debian_action.go | 10 +++--- .../node/service/debian_action_public_test.go | 20 ++++++------ internal/provider/node/service/debian_unit.go | 6 ++-- .../node/service/debian_unit_public_test.go | 32 +++++++++---------- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/internal/provider/node/service/debian_action.go b/internal/provider/node/service/debian_action.go index 32c8030b5..3cddbbc36 100644 --- a/internal/provider/node/service/debian_action.go +++ b/internal/provider/node/service/debian_action.go @@ -46,7 +46,7 @@ func (d *Debian) Start( return &ActionResult{Name: name, Changed: false}, nil } - if _, err := d.execManager.RunCmd("systemctl", []string{"start", unitName}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"start", unitName}); err != nil { return nil, fmt.Errorf("service: start: %w", err) } @@ -72,7 +72,7 @@ func (d *Debian) Stop( return &ActionResult{Name: name, Changed: false}, nil } - if _, err := d.execManager.RunCmd("systemctl", []string{"stop", unitName}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"stop", unitName}); err != nil { return nil, fmt.Errorf("service: stop: %w", err) } @@ -92,7 +92,7 @@ func (d *Debian) Restart( d.logger.Debug("executing service.Restart", slog.String("name", unitName)) - if _, err := d.execManager.RunCmd("systemctl", []string{"restart", unitName}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"restart", unitName}); err != nil { return nil, fmt.Errorf("service: restart: %w", err) } @@ -118,7 +118,7 @@ func (d *Debian) Enable( return &ActionResult{Name: name, Changed: false}, nil } - if _, err := d.execManager.RunCmd("systemctl", []string{"enable", unitName}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"enable", unitName}); err != nil { return nil, fmt.Errorf("service: enable: %w", err) } @@ -144,7 +144,7 @@ func (d *Debian) Disable( return &ActionResult{Name: name, Changed: false}, nil } - if _, err := d.execManager.RunCmd("systemctl", []string{"disable", unitName}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"disable", unitName}); err != nil { return nil, fmt.Errorf("service: disable: %w", err) } diff --git a/internal/provider/node/service/debian_action_public_test.go b/internal/provider/node/service/debian_action_public_test.go index a84ccc7ac..ed1dd2684 100644 --- a/internal/provider/node/service/debian_action_public_test.go +++ b/internal/provider/node/service/debian_action_public_test.go @@ -82,7 +82,7 @@ func (suite *DebianActionPublicTestSuite) TestStart() { RunCmd("systemctl", []string{"is-active", "osapi-nginx.service"}). Return("inactive\n", errors.New("exit status 3")) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"start", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"start", "osapi-nginx.service"}). Return("", nil) }, validateFunc: func( @@ -121,7 +121,7 @@ func (suite *DebianActionPublicTestSuite) TestStart() { RunCmd("systemctl", []string{"is-active", "osapi-nginx.service"}). Return("inactive\n", errors.New("exit status 3")) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"start", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"start", "osapi-nginx.service"}). Return("", errors.New("start failed")) }, validateFunc: func( @@ -174,7 +174,7 @@ func (suite *DebianActionPublicTestSuite) TestStop() { RunCmd("systemctl", []string{"is-active", "osapi-nginx.service"}). Return("active\n", nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"stop", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"stop", "osapi-nginx.service"}). Return("", nil) }, validateFunc: func( @@ -213,7 +213,7 @@ func (suite *DebianActionPublicTestSuite) TestStop() { RunCmd("systemctl", []string{"is-active", "osapi-nginx.service"}). Return("active\n", nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"stop", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"stop", "osapi-nginx.service"}). Return("", errors.New("stop failed")) }, validateFunc: func( @@ -263,7 +263,7 @@ func (suite *DebianActionPublicTestSuite) TestRestart() { serviceName: "nginx", setup: func() { suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"restart", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"restart", "osapi-nginx.service"}). Return("", nil) }, validateFunc: func( @@ -281,7 +281,7 @@ func (suite *DebianActionPublicTestSuite) TestRestart() { serviceName: "nginx", setup: func() { suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"restart", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"restart", "osapi-nginx.service"}). Return("", errors.New("restart failed")) }, validateFunc: func( @@ -334,7 +334,7 @@ func (suite *DebianActionPublicTestSuite) TestEnable() { RunCmd("systemctl", []string{"is-enabled", "osapi-nginx.service"}). Return("disabled\n", errors.New("exit status 1")) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"enable", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"enable", "osapi-nginx.service"}). Return("", nil) }, validateFunc: func( @@ -373,7 +373,7 @@ func (suite *DebianActionPublicTestSuite) TestEnable() { RunCmd("systemctl", []string{"is-enabled", "osapi-nginx.service"}). Return("disabled\n", errors.New("exit status 1")) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"enable", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"enable", "osapi-nginx.service"}). Return("", errors.New("enable failed")) }, validateFunc: func( @@ -426,7 +426,7 @@ func (suite *DebianActionPublicTestSuite) TestDisable() { RunCmd("systemctl", []string{"is-enabled", "osapi-nginx.service"}). Return("enabled\n", nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"disable", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"disable", "osapi-nginx.service"}). Return("", nil) }, validateFunc: func( @@ -465,7 +465,7 @@ func (suite *DebianActionPublicTestSuite) TestDisable() { RunCmd("systemctl", []string{"is-enabled", "osapi-nginx.service"}). Return("enabled\n", nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"disable", "osapi-nginx.service"}). + RunPrivilegedCmd("systemctl", []string{"disable", "osapi-nginx.service"}). Return("", errors.New("disable failed")) }, validateFunc: func( diff --git a/internal/provider/node/service/debian_unit.go b/internal/provider/node/service/debian_unit.go index fa6019c67..ac7033373 100644 --- a/internal/provider/node/service/debian_unit.go +++ b/internal/provider/node/service/debian_unit.go @@ -150,14 +150,14 @@ func (d *Debian) Delete( unitName := managedPrefix + name + ".service" // Best-effort stop and disable before removing the unit file. - if _, err := d.execManager.RunCmd("systemctl", []string{"stop", unitName}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"stop", unitName}); err != nil { d.logger.Warn("failed to stop service before delete", slog.String("name", name), slog.String("error", err.Error()), ) } - if _, err := d.execManager.RunCmd("systemctl", []string{"disable", unitName}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"disable", unitName}); err != nil { d.logger.Warn("failed to disable service before delete", slog.String("name", name), slog.String("error", err.Error()), @@ -196,7 +196,7 @@ func unitFilePath( // daemonReload runs systemctl daemon-reload to pick up unit file changes. func (d *Debian) daemonReload() error { - _, err := d.execManager.RunCmd("systemctl", []string{"daemon-reload"}) + _, err := d.execManager.RunPrivilegedCmd("systemctl", []string{"daemon-reload"}) if err != nil { return fmt.Errorf("daemon-reload: %w", err) } diff --git a/internal/provider/node/service/debian_unit_public_test.go b/internal/provider/node/service/debian_unit_public_test.go index fbe25910e..ee6ab35ea 100644 --- a/internal/provider/node/service/debian_unit_public_test.go +++ b/internal/provider/node/service/debian_unit_public_test.go @@ -122,7 +122,7 @@ func (suite *DebianUnitPublicTestSuite) TestCreate() { Path: "/etc/systemd/system/osapi-myapp.service", }, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", nil) }, validateFunc: func( @@ -187,7 +187,7 @@ func (suite *DebianUnitPublicTestSuite) TestCreate() { Deploy(gomock.Any(), gomock.Any()). Return(&file.DeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", errors.New("exec error")) }, validateFunc: func( @@ -275,7 +275,7 @@ func (suite *DebianUnitPublicTestSuite) TestUpdate() { Path: "/etc/systemd/system/osapi-myapp.service", }, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", nil) }, validateFunc: func( @@ -370,7 +370,7 @@ func (suite *DebianUnitPublicTestSuite) TestUpdate() { Deploy(gomock.Any(), gomock.Any()). Return(&file.DeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", errors.New("exec error")) }, validateFunc: func( @@ -427,7 +427,7 @@ func (suite *DebianUnitPublicTestSuite) TestUpdate() { }). Return(&file.DeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", nil) }, validateFunc: func( @@ -519,10 +519,10 @@ func (suite *DebianUnitPublicTestSuite) TestDelete() { 0o644, ) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"stop", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"stop", "osapi-myapp.service"}). Return("", nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"disable", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"disable", "osapi-myapp.service"}). Return("", nil) suite.mockDeployer.EXPECT(). Undeploy(gomock.Any(), file.UndeployRequest{ @@ -533,7 +533,7 @@ func (suite *DebianUnitPublicTestSuite) TestDelete() { Path: "/etc/systemd/system/osapi-myapp.service", }, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", nil) }, validateFunc: func( @@ -568,10 +568,10 @@ func (suite *DebianUnitPublicTestSuite) TestDelete() { 0o644, ) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"stop", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"stop", "osapi-myapp.service"}). Return("", nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"disable", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"disable", "osapi-myapp.service"}). Return("", nil) suite.mockDeployer.EXPECT(). Undeploy(gomock.Any(), gomock.Any()). @@ -596,16 +596,16 @@ func (suite *DebianUnitPublicTestSuite) TestDelete() { 0o644, ) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"stop", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"stop", "osapi-myapp.service"}). Return("", nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"disable", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"disable", "osapi-myapp.service"}). Return("", nil) suite.mockDeployer.EXPECT(). Undeploy(gomock.Any(), gomock.Any()). Return(&file.UndeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", errors.New("exec error")) }, validateFunc: func( @@ -627,10 +627,10 @@ func (suite *DebianUnitPublicTestSuite) TestDelete() { 0o644, ) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"stop", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"stop", "osapi-myapp.service"}). Return("", errors.New("stop error")) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"disable", "osapi-myapp.service"}). + RunPrivilegedCmd("systemctl", []string{"disable", "osapi-myapp.service"}). Return("", errors.New("disable error")) suite.mockDeployer.EXPECT(). Undeploy(gomock.Any(), file.UndeployRequest{ @@ -641,7 +641,7 @@ func (suite *DebianUnitPublicTestSuite) TestDelete() { Path: "/etc/systemd/system/osapi-myapp.service", }, nil) suite.mockExecManager.EXPECT(). - RunCmd("systemctl", []string{"daemon-reload"}). + RunPrivilegedCmd("systemctl", []string{"daemon-reload"}). Return("", nil) }, validateFunc: func( From 018342fa8e3a0d6e5f2ca29aedb0e89fd7edc8a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 14:32:38 -0700 Subject: [PATCH 09/21] refactor(providers): use RunPrivilegedCmd for sysctl, host, timezone, ntp writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate write operations in four providers to use RunPrivilegedCmd so they are executed with privilege escalation when configured: - sysctl: sysctl -p and sysctl --system - host: hostnamectl set-hostname - timezone: timedatectl set-timezone - ntp: chronyc reload sources Read-only operations (sysctl -n, hostnamectl hostname, timedatectl show, chronyc tracking/sources) remain as RunCmd. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../provider/node/host/debian_update_hostname.go | 2 +- .../node/host/debian_update_hostname_public_test.go | 4 ++-- internal/provider/node/ntp/debian.go | 2 +- internal/provider/node/ntp/debian_public_test.go | 12 ++++++------ internal/provider/node/sysctl/debian.go | 4 ++-- internal/provider/node/sysctl/debian_public_test.go | 12 ++++++------ internal/provider/node/timezone/debian.go | 2 +- .../provider/node/timezone/debian_public_test.go | 4 ++-- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/internal/provider/node/host/debian_update_hostname.go b/internal/provider/node/host/debian_update_hostname.go index bdbfb8cbc..e9e514817 100644 --- a/internal/provider/node/host/debian_update_hostname.go +++ b/internal/provider/node/host/debian_update_hostname.go @@ -40,7 +40,7 @@ func (u *Debian) UpdateHostname( return &UpdateHostnameResult{Changed: false}, nil } - if _, err := u.execManager.RunCmd("hostnamectl", []string{"set-hostname", name}); err != nil { + if _, err := u.execManager.RunPrivilegedCmd("hostnamectl", []string{"set-hostname", name}); err != nil { return nil, fmt.Errorf("failed to set hostname: %w", err) } diff --git a/internal/provider/node/host/debian_update_hostname_public_test.go b/internal/provider/node/host/debian_update_hostname_public_test.go index 57f540d54..06004f1e2 100644 --- a/internal/provider/node/host/debian_update_hostname_public_test.go +++ b/internal/provider/node/host/debian_update_hostname_public_test.go @@ -67,7 +67,7 @@ func (suite *DebianUpdateHostnamePublicTestSuite) TestUpdateHostname() { RunCmd("hostnamectl", []string{"hostname"}). Return("old-host", nil), mock.EXPECT(). - RunCmd("hostnamectl", []string{"set-hostname", "new-host"}). + RunPrivilegedCmd("hostnamectl", []string{"set-hostname", "new-host"}). Return("", nil), ) return mock @@ -124,7 +124,7 @@ func (suite *DebianUpdateHostnamePublicTestSuite) TestUpdateHostname() { RunCmd("hostnamectl", []string{"hostname"}). Return("old-host", nil), mock.EXPECT(). - RunCmd("hostnamectl", []string{"set-hostname", "new-host"}). + RunPrivilegedCmd("hostnamectl", []string{"set-hostname", "new-host"}). Return("", assert.AnError), ) return mock diff --git a/internal/provider/node/ntp/debian.go b/internal/provider/node/ntp/debian.go index 2830cd521..094621522 100644 --- a/internal/provider/node/ntp/debian.go +++ b/internal/provider/node/ntp/debian.go @@ -188,7 +188,7 @@ func (d *Debian) Delete( // reloadSources runs chronyc reload sources. Failures are logged as // warnings but do not fail the operation. func (d *Debian) reloadSources() { - if _, err := d.execManager.RunCmd("chronyc", []string{"reload", "sources"}); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("chronyc", []string{"reload", "sources"}); err != nil { d.logger.Warn( "chronyc reload sources failed", slog.String("error", err.Error()), diff --git a/internal/provider/node/ntp/debian_public_test.go b/internal/provider/node/ntp/debian_public_test.go index 9951d1f21..9d724cca3 100644 --- a/internal/provider/node/ntp/debian_public_test.go +++ b/internal/provider/node/ntp/debian_public_test.go @@ -210,7 +210,7 @@ func (suite *DebianPublicTestSuite) TestCreate() { setupFs: func() {}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chronyc", []string{"reload", "sources"}). + RunPrivilegedCmd("chronyc", []string{"reload", "sources"}). Return("", nil) }, validateFunc: func(r *ntp.CreateResult) { @@ -306,7 +306,7 @@ func (suite *DebianPublicTestSuite) TestCreate() { setupFs: func() {}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chronyc", []string{"reload", "sources"}). + RunPrivilegedCmd("chronyc", []string{"reload", "sources"}). Return("", errors.New("chronyc not running")) }, validateFunc: func(r *ntp.CreateResult) { @@ -365,7 +365,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { }, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chronyc", []string{"reload", "sources"}). + RunPrivilegedCmd("chronyc", []string{"reload", "sources"}). Return("", nil) }, validateFunc: func(r *ntp.UpdateResult) { @@ -464,7 +464,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { }, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chronyc", []string{"reload", "sources"}). + RunPrivilegedCmd("chronyc", []string{"reload", "sources"}). Return("", errors.New("chronyc not running")) }, validateFunc: func(r *ntp.UpdateResult) { @@ -519,7 +519,7 @@ func (suite *DebianPublicTestSuite) TestDelete() { }, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chronyc", []string{"reload", "sources"}). + RunPrivilegedCmd("chronyc", []string{"reload", "sources"}). Return("", nil) }, validateFunc: func(r *ntp.DeleteResult) { @@ -577,7 +577,7 @@ func (suite *DebianPublicTestSuite) TestDelete() { }, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chronyc", []string{"reload", "sources"}). + RunPrivilegedCmd("chronyc", []string{"reload", "sources"}). Return("", errors.New("chronyc not running")) }, validateFunc: func(r *ntp.DeleteResult) { diff --git a/internal/provider/node/sysctl/debian.go b/internal/provider/node/sysctl/debian.go index bf8f0123d..f2110de2c 100644 --- a/internal/provider/node/sysctl/debian.go +++ b/internal/provider/node/sysctl/debian.go @@ -227,7 +227,7 @@ func (d *Debian) deploy( } // Apply the sysctl conf file. - if _, execErr := d.execManager.RunCmd("sysctl", []string{"-p", confPath}); execErr != nil { + if _, execErr := d.execManager.RunPrivilegedCmd("sysctl", []string{"-p", confPath}); execErr != nil { d.logger.Warn( "sysctl apply failed after deploy", slog.String("key", entry.Key), @@ -310,7 +310,7 @@ func (d *Debian) Delete( // Reload sysctl defaults. if changed { - if _, execErr := d.execManager.RunCmd("sysctl", []string{"--system"}); execErr != nil { + if _, execErr := d.execManager.RunPrivilegedCmd("sysctl", []string{"--system"}); execErr != nil { d.logger.Warn( "sysctl reload failed after delete", slog.String("key", key), diff --git a/internal/provider/node/sysctl/debian_public_test.go b/internal/provider/node/sysctl/debian_public_test.go index 6be9b18d3..46b752cc5 100644 --- a/internal/provider/node/sysctl/debian_public_test.go +++ b/internal/provider/node/sysctl/debian_public_test.go @@ -127,7 +127,7 @@ func (suite *DebianPublicTestSuite) TestCreate() { Put(gomock.Any(), gomock.Any(), gomock.Any()). Return(uint64(1), nil) suite.mockExec.EXPECT(). - RunCmd("sysctl", []string{"-p", "/etc/sysctl.d/osapi-net.ipv4.ip_forward.conf"}). + RunPrivilegedCmd("sysctl", []string{"-p", "/etc/sysctl.d/osapi-net.ipv4.ip_forward.conf"}). Return("", nil) }, validateFunc: func( @@ -237,7 +237,7 @@ func (suite *DebianPublicTestSuite) TestCreate() { Put(gomock.Any(), gomock.Any(), gomock.Any()). Return(uint64(1), nil) suite.mockExec.EXPECT(). - RunCmd("sysctl", gomock.Any()). + RunPrivilegedCmd("sysctl", gomock.Any()). Return("", nil) }, validateFunc: func( @@ -311,7 +311,7 @@ func (suite *DebianPublicTestSuite) TestCreate() { Put(gomock.Any(), gomock.Any(), gomock.Any()). Return(uint64(1), nil) suite.mockExec.EXPECT(). - RunCmd("sysctl", gomock.Any()). + RunPrivilegedCmd("sysctl", gomock.Any()). Return("", errors.New("sysctl failed")) }, validateFunc: func( @@ -366,7 +366,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { Put(gomock.Any(), gomock.Any(), gomock.Any()). Return(uint64(1), nil) suite.mockExec.EXPECT(). - RunCmd("sysctl", []string{"-p", "/etc/sysctl.d/osapi-net.ipv4.ip_forward.conf"}). + RunPrivilegedCmd("sysctl", []string{"-p", "/etc/sysctl.d/osapi-net.ipv4.ip_forward.conf"}). Return("", nil) }, validateFunc: func( @@ -656,7 +656,7 @@ func (suite *DebianPublicTestSuite) TestDelete() { Put(gomock.Any(), gomock.Any(), gomock.Any()). Return(uint64(1), nil) suite.mockExec.EXPECT(). - RunCmd("sysctl", []string{"--system"}). + RunPrivilegedCmd("sysctl", []string{"--system"}). Return("", nil) }, validateFunc: func( @@ -911,7 +911,7 @@ func (suite *DebianPublicTestSuite) TestDelete() { Put(gomock.Any(), gomock.Any(), gomock.Any()). Return(uint64(1), nil) suite.mockExec.EXPECT(). - RunCmd("sysctl", []string{"--system"}). + RunPrivilegedCmd("sysctl", []string{"--system"}). Return("", errors.New("sysctl failed")) }, validateFunc: func( diff --git a/internal/provider/node/timezone/debian.go b/internal/provider/node/timezone/debian.go index 59b1d6549..7302c6664 100644 --- a/internal/provider/node/timezone/debian.go +++ b/internal/provider/node/timezone/debian.go @@ -105,7 +105,7 @@ func (d *Debian) Update( }, nil } - if _, setErr := d.execManager.RunCmd("timedatectl", []string{"set-timezone", timezone}); setErr != nil { + if _, setErr := d.execManager.RunPrivilegedCmd("timedatectl", []string{"set-timezone", timezone}); setErr != nil { return nil, fmt.Errorf("timezone: set-timezone: %w", setErr) } diff --git a/internal/provider/node/timezone/debian_public_test.go b/internal/provider/node/timezone/debian_public_test.go index bfb7e239b..d512f1afe 100644 --- a/internal/provider/node/timezone/debian_public_test.go +++ b/internal/provider/node/timezone/debian_public_test.go @@ -147,7 +147,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { RunCmd("timedatectl", []string{"show", "-p", "Timezone", "--value"}). Return("America/New_York\n", nil) suite.mockExec.EXPECT(). - RunCmd("timedatectl", []string{"set-timezone", "Europe/London"}). + RunPrivilegedCmd("timedatectl", []string{"set-timezone", "Europe/London"}). Return("", nil) }, validateFunc: func(r *timezone.UpdateResult) { @@ -187,7 +187,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { RunCmd("timedatectl", []string{"show", "-p", "Timezone", "--value"}). Return("America/New_York\n", nil) suite.mockExec.EXPECT(). - RunCmd("timedatectl", []string{"set-timezone", "Europe/London"}). + RunPrivilegedCmd("timedatectl", []string{"set-timezone", "Europe/London"}). Return("", errors.New("permission denied")) }, wantErr: true, From 19244eb578bde5d0b0eb2a9b5056ff92fd0de61f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 14:37:09 -0700 Subject: [PATCH 10/21] refactor(providers): use RunPrivilegedCmd for user, apt, power, certificate, dns writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate write operations from RunCmd to RunPrivilegedCmd across five provider domains so privileged commands use sudo/capabilities when configured. Read-only commands (id, passwd -S, dpkg-query, apt list, resolvectl status) remain as RunCmd. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/exec/mocks/dns.go | 8 +-- .../debian_update_resolv_conf_by_interface.go | 4 +- internal/provider/node/apt/debian.go | 6 +-- .../provider/node/apt/debian_public_test.go | 12 ++--- internal/provider/node/certificate/debian.go | 2 +- .../node/certificate/debian_public_test.go | 14 +++--- internal/provider/node/power/debian.go | 2 +- .../provider/node/power/debian_public_test.go | 24 ++++----- internal/provider/node/user/debian_group.go | 6 +-- .../provider/node/user/debian_public_test.go | 50 +++++++++---------- internal/provider/node/user/debian_ssh_key.go | 2 +- .../node/user/debian_ssh_key_public_test.go | 8 +-- internal/provider/node/user/debian_user.go | 8 +-- 13 files changed, 73 insertions(+), 73 deletions(-) diff --git a/internal/exec/mocks/dns.go b/internal/exec/mocks/dns.go index c4889bcca..7d29f72a5 100644 --- a/internal/exec/mocks/dns.go +++ b/internal/exec/mocks/dns.go @@ -258,18 +258,18 @@ func mockRunCmdStatus(mock *MockManager, output string) { AnyTimes() } -// mockRunCmdDNS sets up a mock for the "dns" RunCmd call. +// mockRunCmdDNS sets up a mock for the "dns" RunPrivilegedCmd call. func mockRunCmdDNS(mock *MockManager, dnsServers []string, err error) { mock.EXPECT(). - RunCmd(ResolveCommand, append([]string{"dns", NetworkInterfaceName}, dnsServers...)). + RunPrivilegedCmd(ResolveCommand, append([]string{"dns", NetworkInterfaceName}, dnsServers...)). Return("", err). AnyTimes() } -// mockRunCmdDomain sets up a mock for the "domain" RunCmd call. +// mockRunCmdDomain sets up a mock for the "domain" RunPrivilegedCmd call. func mockRunCmdDomain(mock *MockManager, domains []string, err error) { mock.EXPECT(). - RunCmd(ResolveCommand, append([]string{"domain", NetworkInterfaceName}, domains...)). + RunPrivilegedCmd(ResolveCommand, append([]string{"domain", NetworkInterfaceName}, domains...)). Return("", err). AnyTimes() } diff --git a/internal/provider/network/dns/debian_update_resolv_conf_by_interface.go b/internal/provider/network/dns/debian_update_resolv_conf_by_interface.go index 186a988d9..79d6feeb8 100644 --- a/internal/provider/network/dns/debian_update_resolv_conf_by_interface.go +++ b/internal/provider/network/dns/debian_update_resolv_conf_by_interface.go @@ -91,7 +91,7 @@ func (u *Debian) UpdateResolvConfByInterface( if len(servers) > 0 { cmd := "resolvectl" args := append([]string{"dns", interfaceName}, servers...) - output, err := u.execManager.RunCmd(cmd, args) + output, err := u.execManager.RunPrivilegedCmd(cmd, args) if err != nil { return nil, fmt.Errorf( "failed to set DNS servers with resolvectl: %w - %s", @@ -110,7 +110,7 @@ func (u *Debian) UpdateResolvConfByInterface( if len(filteredDomains) > 0 { cmd := "resolvectl" args := append([]string{"domain", interfaceName}, filteredDomains...) - output, err := u.execManager.RunCmd(cmd, args) + output, err := u.execManager.RunPrivilegedCmd(cmd, args) if err != nil { return nil, fmt.Errorf( "failed to set search domains with resolvectl: %w - %s", diff --git a/internal/provider/node/apt/debian.go b/internal/provider/node/apt/debian.go index 842c3ca84..de4bded1a 100644 --- a/internal/provider/node/apt/debian.go +++ b/internal/provider/node/apt/debian.go @@ -100,7 +100,7 @@ func (d *Debian) Install( _ context.Context, name string, ) (*Result, error) { - _, err := d.execManager.RunCmd( + _, err := d.execManager.RunPrivilegedCmd( "apt-get", []string{"install", "-y", name}, ) @@ -124,7 +124,7 @@ func (d *Debian) Remove( _ context.Context, name string, ) (*Result, error) { - _, err := d.execManager.RunCmd( + _, err := d.execManager.RunPrivilegedCmd( "apt-get", []string{"remove", "-y", name}, ) @@ -147,7 +147,7 @@ func (d *Debian) Remove( func (d *Debian) Update( _ context.Context, ) (*Result, error) { - _, err := d.execManager.RunCmd( + _, err := d.execManager.RunPrivilegedCmd( "apt-get", []string{"update"}, ) diff --git a/internal/provider/node/apt/debian_public_test.go b/internal/provider/node/apt/debian_public_test.go index 14a73bcdc..1f5db0820 100644 --- a/internal/provider/node/apt/debian_public_test.go +++ b/internal/provider/node/apt/debian_public_test.go @@ -241,7 +241,7 @@ func (suite *DebianPublicTestSuite) TestInstall() { pkgName: "vim", setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("apt-get", []string{"install", "-y", "vim"}). + RunPrivilegedCmd("apt-get", []string{"install", "-y", "vim"}). Return("", nil) }, validateFunc: func(result *apt.Result) { @@ -255,7 +255,7 @@ func (suite *DebianPublicTestSuite) TestInstall() { pkgName: "badpkg", setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("apt-get", []string{"install", "-y", "badpkg"}). + RunPrivilegedCmd("apt-get", []string{"install", "-y", "badpkg"}). Return("", fmt.Errorf("E: Unable to locate package badpkg")) }, wantErr: true, @@ -296,7 +296,7 @@ func (suite *DebianPublicTestSuite) TestRemove() { pkgName: "vim", setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("apt-get", []string{"remove", "-y", "vim"}). + RunPrivilegedCmd("apt-get", []string{"remove", "-y", "vim"}). Return("", nil) }, validateFunc: func(result *apt.Result) { @@ -310,7 +310,7 @@ func (suite *DebianPublicTestSuite) TestRemove() { pkgName: "vim", setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("apt-get", []string{"remove", "-y", "vim"}). + RunPrivilegedCmd("apt-get", []string{"remove", "-y", "vim"}). Return("", fmt.Errorf("permission denied")) }, wantErr: true, @@ -349,7 +349,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { name: "when update succeeds", setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("apt-get", []string{"update"}). + RunPrivilegedCmd("apt-get", []string{"update"}). Return("", nil) }, validateFunc: func(result *apt.Result) { @@ -361,7 +361,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { name: "when exec error", setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("apt-get", []string{"update"}). + RunPrivilegedCmd("apt-get", []string{"update"}). Return("", fmt.Errorf("permission denied")) }, wantErr: true, diff --git a/internal/provider/node/certificate/debian.go b/internal/provider/node/certificate/debian.go index 01984bb5f..68491534f 100644 --- a/internal/provider/node/certificate/debian.go +++ b/internal/provider/node/certificate/debian.go @@ -215,7 +215,7 @@ func certFilePath( // updateCACertificates runs update-ca-certificates to rebuild the trust store. func (d *Debian) updateCACertificates() error { - _, err := d.execManager.RunCmd("update-ca-certificates", nil) + _, err := d.execManager.RunPrivilegedCmd("update-ca-certificates", nil) if err != nil { return fmt.Errorf("update-ca-certificates: %w", err) } diff --git a/internal/provider/node/certificate/debian_public_test.go b/internal/provider/node/certificate/debian_public_test.go index 9faa2e9b7..47cdcbc19 100644 --- a/internal/provider/node/certificate/debian_public_test.go +++ b/internal/provider/node/certificate/debian_public_test.go @@ -104,7 +104,7 @@ func (suite *DebianPublicTestSuite) TestCreate() { Path: "/usr/local/share/ca-certificates/osapi-my-ca.crt", }, nil) suite.mockExecManager.EXPECT(). - RunCmd("update-ca-certificates", nil). + RunPrivilegedCmd("update-ca-certificates", nil). Return("", nil) }, validateFunc: func( @@ -169,7 +169,7 @@ func (suite *DebianPublicTestSuite) TestCreate() { Deploy(gomock.Any(), gomock.Any()). Return(&file.DeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("update-ca-certificates", nil). + RunPrivilegedCmd("update-ca-certificates", nil). Return("", errors.New("exec error")) }, validateFunc: func( @@ -273,7 +273,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { Path: "/usr/local/share/ca-certificates/osapi-my-ca.crt", }, nil) suite.mockExecManager.EXPECT(). - RunCmd("update-ca-certificates", nil). + RunPrivilegedCmd("update-ca-certificates", nil). Return("", nil) }, validateFunc: func( @@ -368,7 +368,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { Deploy(gomock.Any(), gomock.Any()). Return(&file.DeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("update-ca-certificates", nil). + RunPrivilegedCmd("update-ca-certificates", nil). Return("", errors.New("exec error")) }, validateFunc: func( @@ -425,7 +425,7 @@ func (suite *DebianPublicTestSuite) TestUpdate() { }). Return(&file.DeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("update-ca-certificates", nil). + RunPrivilegedCmd("update-ca-certificates", nil). Return("", nil) }, validateFunc: func( @@ -525,7 +525,7 @@ func (suite *DebianPublicTestSuite) TestDelete() { Path: "/usr/local/share/ca-certificates/osapi-my-ca.crt", }, nil) suite.mockExecManager.EXPECT(). - RunCmd("update-ca-certificates", nil). + RunPrivilegedCmd("update-ca-certificates", nil). Return("", nil) }, validateFunc: func( @@ -585,7 +585,7 @@ func (suite *DebianPublicTestSuite) TestDelete() { Undeploy(gomock.Any(), gomock.Any()). Return(&file.UndeployResult{Changed: true}, nil) suite.mockExecManager.EXPECT(). - RunCmd("update-ca-certificates", nil). + RunPrivilegedCmd("update-ca-certificates", nil). Return("", errors.New("exec error")) }, validateFunc: func( diff --git a/internal/provider/node/power/debian.go b/internal/provider/node/power/debian.go index d294a6aa8..d7cd85e0f 100644 --- a/internal/provider/node/power/debian.go +++ b/internal/provider/node/power/debian.go @@ -102,7 +102,7 @@ func (d *Debian) executePowerAction( args = append(args, opts.Message) } - if _, err := d.execManager.RunCmd("shutdown", args); err != nil { + if _, err := d.execManager.RunPrivilegedCmd("shutdown", args); err != nil { return nil, fmt.Errorf("power: %s: %w", action, err) } diff --git a/internal/provider/node/power/debian_public_test.go b/internal/provider/node/power/debian_public_test.go index 9226db49e..1e2475482 100644 --- a/internal/provider/node/power/debian_public_test.go +++ b/internal/provider/node/power/debian_public_test.go @@ -72,7 +72,7 @@ func (suite *DebianPublicTestSuite) TestReboot() { opts: power.Opts{}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-r", "+1"}). + RunPrivilegedCmd("shutdown", []string{"-r", "+1"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -86,7 +86,7 @@ func (suite *DebianPublicTestSuite) TestReboot() { opts: power.Opts{Delay: 180}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-r", "+3"}). + RunPrivilegedCmd("shutdown", []string{"-r", "+3"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -100,7 +100,7 @@ func (suite *DebianPublicTestSuite) TestReboot() { opts: power.Opts{Delay: 30}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-r", "+1"}). + RunPrivilegedCmd("shutdown", []string{"-r", "+1"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -114,7 +114,7 @@ func (suite *DebianPublicTestSuite) TestReboot() { opts: power.Opts{Message: "maintenance reboot"}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-r", "+1", "maintenance reboot"}). + RunPrivilegedCmd("shutdown", []string{"-r", "+1", "maintenance reboot"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -128,7 +128,7 @@ func (suite *DebianPublicTestSuite) TestReboot() { opts: power.Opts{Delay: 300, Message: "scheduled reboot"}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-r", "+5", "scheduled reboot"}). + RunPrivilegedCmd("shutdown", []string{"-r", "+5", "scheduled reboot"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -142,7 +142,7 @@ func (suite *DebianPublicTestSuite) TestReboot() { opts: power.Opts{}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-r", "+1"}). + RunPrivilegedCmd("shutdown", []string{"-r", "+1"}). Return("", errors.New("permission denied")) }, wantErr: true, @@ -185,7 +185,7 @@ func (suite *DebianPublicTestSuite) TestShutdown() { opts: power.Opts{}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-h", "+1"}). + RunPrivilegedCmd("shutdown", []string{"-h", "+1"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -199,7 +199,7 @@ func (suite *DebianPublicTestSuite) TestShutdown() { opts: power.Opts{Delay: 180}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-h", "+3"}). + RunPrivilegedCmd("shutdown", []string{"-h", "+3"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -213,7 +213,7 @@ func (suite *DebianPublicTestSuite) TestShutdown() { opts: power.Opts{Delay: 30}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-h", "+1"}). + RunPrivilegedCmd("shutdown", []string{"-h", "+1"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -227,7 +227,7 @@ func (suite *DebianPublicTestSuite) TestShutdown() { opts: power.Opts{Message: "maintenance shutdown"}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-h", "+1", "maintenance shutdown"}). + RunPrivilegedCmd("shutdown", []string{"-h", "+1", "maintenance shutdown"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -241,7 +241,7 @@ func (suite *DebianPublicTestSuite) TestShutdown() { opts: power.Opts{Delay: 300, Message: "scheduled shutdown"}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-h", "+5", "scheduled shutdown"}). + RunPrivilegedCmd("shutdown", []string{"-h", "+5", "scheduled shutdown"}). Return("", nil) }, validateFunc: func(r *power.Result) { @@ -255,7 +255,7 @@ func (suite *DebianPublicTestSuite) TestShutdown() { opts: power.Opts{}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("shutdown", []string{"-h", "+1"}). + RunPrivilegedCmd("shutdown", []string{"-h", "+1"}). Return("", errors.New("permission denied")) }, wantErr: true, diff --git a/internal/provider/node/user/debian_group.go b/internal/provider/node/user/debian_group.go index f544acc7d..bc364f45b 100644 --- a/internal/provider/node/user/debian_group.go +++ b/internal/provider/node/user/debian_group.go @@ -78,7 +78,7 @@ func (d *Debian) CreateGroup( args := d.buildGroupaddArgs(opts) - _, err := d.execManager.RunCmd("groupadd", args) + _, err := d.execManager.RunPrivilegedCmd("groupadd", args) if err != nil { return nil, fmt.Errorf("group: groupadd failed: %w", err) } @@ -103,7 +103,7 @@ func (d *Debian) UpdateGroup( members := strings.Join(opts.Members, ",") - _, err := d.execManager.RunCmd("gpasswd", []string{"-M", members, name}) + _, err := d.execManager.RunPrivilegedCmd("gpasswd", []string{"-M", members, name}) if err != nil { return nil, fmt.Errorf("group: gpasswd failed: %w", err) } @@ -125,7 +125,7 @@ func (d *Debian) DeleteGroup( ) (*GroupResult, error) { _ = ctx - _, err := d.execManager.RunCmd("groupdel", []string{name}) + _, err := d.execManager.RunPrivilegedCmd("groupdel", []string{name}) if err != nil { return nil, fmt.Errorf("group: groupdel failed: %w", err) } diff --git a/internal/provider/node/user/debian_public_test.go b/internal/provider/node/user/debian_public_test.go index 70db98d24..5b7327bc2 100644 --- a/internal/provider/node/user/debian_public_test.go +++ b/internal/provider/node/user/debian_public_test.go @@ -356,7 +356,7 @@ func (suite *DebianPublicTestSuite) TestCreateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("useradd", []string{"--create-home", "newuser"}). + RunPrivilegedCmd("useradd", []string{"--create-home", "newuser"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -379,7 +379,7 @@ func (suite *DebianPublicTestSuite) TestCreateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("useradd", []string{ + RunPrivilegedCmd("useradd", []string{ "--create-home", "-u", "2000", "-g", "2000", @@ -406,10 +406,10 @@ func (suite *DebianPublicTestSuite) TestCreateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("useradd", []string{"--create-home", "newuser"}). + RunPrivilegedCmd("useradd", []string{"--create-home", "newuser"}). Return("", nil) suite.mockExec.EXPECT(). - RunCmd("sh", []string{"-c", "echo 'newuser:secret123' | chpasswd"}). + RunPrivilegedCmd("sh", []string{"-c", "echo 'newuser:secret123' | chpasswd"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -426,7 +426,7 @@ func (suite *DebianPublicTestSuite) TestCreateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("useradd", []string{"--create-home", "newuser"}). + RunPrivilegedCmd("useradd", []string{"--create-home", "newuser"}). Return("", errors.New("user already exists")) }, validateFunc: func(result *user.Result, err error) { @@ -443,10 +443,10 @@ func (suite *DebianPublicTestSuite) TestCreateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("useradd", []string{"--create-home", "newuser"}). + RunPrivilegedCmd("useradd", []string{"--create-home", "newuser"}). Return("", nil) suite.mockExec.EXPECT(). - RunCmd("sh", []string{"-c", "echo 'newuser:secret123' | chpasswd"}). + RunPrivilegedCmd("sh", []string{"-c", "echo 'newuser:secret123' | chpasswd"}). Return("", errors.New("chpasswd failed")) }, validateFunc: func(result *user.Result, err error) { @@ -486,7 +486,7 @@ func (suite *DebianPublicTestSuite) TestUpdateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("usermod", []string{"-s", "/bin/zsh", "john"}). + RunPrivilegedCmd("usermod", []string{"-s", "/bin/zsh", "john"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -504,7 +504,7 @@ func (suite *DebianPublicTestSuite) TestUpdateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("usermod", []string{"-G", "sudo,docker,admin", "john"}). + RunPrivilegedCmd("usermod", []string{"-G", "sudo,docker,admin", "john"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -521,7 +521,7 @@ func (suite *DebianPublicTestSuite) TestUpdateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("usermod", []string{"-L", "john"}). + RunPrivilegedCmd("usermod", []string{"-L", "john"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -538,7 +538,7 @@ func (suite *DebianPublicTestSuite) TestUpdateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("usermod", []string{"-U", "john"}). + RunPrivilegedCmd("usermod", []string{"-U", "john"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -555,7 +555,7 @@ func (suite *DebianPublicTestSuite) TestUpdateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("usermod", []string{"-d", "/opt/john", "-m", "john"}). + RunPrivilegedCmd("usermod", []string{"-d", "/opt/john", "-m", "john"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -584,7 +584,7 @@ func (suite *DebianPublicTestSuite) TestUpdateUser() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("usermod", []string{"-s", "/bin/zsh", "john"}). + RunPrivilegedCmd("usermod", []string{"-s", "/bin/zsh", "john"}). Return("", errors.New("usermod error")) }, validateFunc: func(result *user.Result, err error) { @@ -617,7 +617,7 @@ func (suite *DebianPublicTestSuite) TestDeleteUser() { userName: "john", setup: func() { suite.mockExec.EXPECT(). - RunCmd("userdel", []string{"-r", "john"}). + RunPrivilegedCmd("userdel", []string{"-r", "john"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -632,7 +632,7 @@ func (suite *DebianPublicTestSuite) TestDeleteUser() { userName: "nonexistent", setup: func() { suite.mockExec.EXPECT(). - RunCmd("userdel", []string{"-r", "nonexistent"}). + RunPrivilegedCmd("userdel", []string{"-r", "nonexistent"}). Return("", errors.New("user does not exist")) }, validateFunc: func(result *user.Result, err error) { @@ -667,7 +667,7 @@ func (suite *DebianPublicTestSuite) TestChangePassword() { password: "newpassword", setup: func() { suite.mockExec.EXPECT(). - RunCmd("sh", []string{"-c", "echo 'john:newpassword' | chpasswd"}). + RunPrivilegedCmd("sh", []string{"-c", "echo 'john:newpassword' | chpasswd"}). Return("", nil) }, validateFunc: func(result *user.Result, err error) { @@ -683,7 +683,7 @@ func (suite *DebianPublicTestSuite) TestChangePassword() { password: "newpassword", setup: func() { suite.mockExec.EXPECT(). - RunCmd("sh", []string{"-c", "echo 'john:newpassword' | chpasswd"}). + RunPrivilegedCmd("sh", []string{"-c", "echo 'john:newpassword' | chpasswd"}). Return("", errors.New("chpasswd error")) }, validateFunc: func(result *user.Result, err error) { @@ -874,7 +874,7 @@ func (suite *DebianPublicTestSuite) TestCreateGroup() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("groupadd", []string{"newgroup"}). + RunPrivilegedCmd("groupadd", []string{"newgroup"}). Return("", nil) }, validateFunc: func(result *user.GroupResult, err error) { @@ -892,7 +892,7 @@ func (suite *DebianPublicTestSuite) TestCreateGroup() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("groupadd", []string{"-g", "5000", "newgroup"}). + RunPrivilegedCmd("groupadd", []string{"-g", "5000", "newgroup"}). Return("", nil) }, validateFunc: func(result *user.GroupResult, err error) { @@ -910,7 +910,7 @@ func (suite *DebianPublicTestSuite) TestCreateGroup() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("groupadd", []string{"-r", "sysgroup"}). + RunPrivilegedCmd("groupadd", []string{"-r", "sysgroup"}). Return("", nil) }, validateFunc: func(result *user.GroupResult, err error) { @@ -927,7 +927,7 @@ func (suite *DebianPublicTestSuite) TestCreateGroup() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("groupadd", []string{"newgroup"}). + RunPrivilegedCmd("groupadd", []string{"newgroup"}). Return("", errors.New("group already exists")) }, validateFunc: func(result *user.GroupResult, err error) { @@ -964,7 +964,7 @@ func (suite *DebianPublicTestSuite) TestUpdateGroup() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("gpasswd", []string{"-M", "john,jane,alice", "developers"}). + RunPrivilegedCmd("gpasswd", []string{"-M", "john,jane,alice", "developers"}). Return("", nil) }, validateFunc: func(result *user.GroupResult, err error) { @@ -982,7 +982,7 @@ func (suite *DebianPublicTestSuite) TestUpdateGroup() { }, setup: func() { suite.mockExec.EXPECT(). - RunCmd("gpasswd", []string{"-M", "john", "developers"}). + RunPrivilegedCmd("gpasswd", []string{"-M", "john", "developers"}). Return("", errors.New("gpasswd error")) }, validateFunc: func(result *user.GroupResult, err error) { @@ -1015,7 +1015,7 @@ func (suite *DebianPublicTestSuite) TestDeleteGroup() { groupName: "developers", setup: func() { suite.mockExec.EXPECT(). - RunCmd("groupdel", []string{"developers"}). + RunPrivilegedCmd("groupdel", []string{"developers"}). Return("", nil) }, validateFunc: func(result *user.GroupResult, err error) { @@ -1030,7 +1030,7 @@ func (suite *DebianPublicTestSuite) TestDeleteGroup() { groupName: "nonexistent", setup: func() { suite.mockExec.EXPECT(). - RunCmd("groupdel", []string{"nonexistent"}). + RunPrivilegedCmd("groupdel", []string{"nonexistent"}). Return("", errors.New("group does not exist")) }, validateFunc: func(result *user.GroupResult, err error) { diff --git a/internal/provider/node/user/debian_ssh_key.go b/internal/provider/node/user/debian_ssh_key.go index 4185cc48f..0a4253007 100644 --- a/internal/provider/node/user/debian_ssh_key.go +++ b/internal/provider/node/user/debian_ssh_key.go @@ -138,7 +138,7 @@ func (d *Debian) AddKey( } // Best-effort chown. - _, chownErr := d.execManager.RunCmd("chown", []string{ + _, chownErr := d.execManager.RunPrivilegedCmd("chown", []string{ "-R", username + ":" + username, sshDir, diff --git a/internal/provider/node/user/debian_ssh_key_public_test.go b/internal/provider/node/user/debian_ssh_key_public_test.go index b47a6a689..69bd23d34 100644 --- a/internal/provider/node/user/debian_ssh_key_public_test.go +++ b/internal/provider/node/user/debian_ssh_key_public_test.go @@ -385,7 +385,7 @@ func (suite *DebianSSHKeyPublicTestSuite) TestAddKey() { setupFS: func() {}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + RunPrivilegedCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). Return("", nil) }, validateFunc: func(result *user.SSHKeyResult, err error) { @@ -441,7 +441,7 @@ func (suite *DebianSSHKeyPublicTestSuite) TestAddKey() { }, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + RunPrivilegedCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). Return("", nil) }, validateFunc: func(result *user.SSHKeyResult, err error) { @@ -480,7 +480,7 @@ func (suite *DebianSSHKeyPublicTestSuite) TestAddKey() { }, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + RunPrivilegedCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). Return("", nil) }, validateFunc: func(result *user.SSHKeyResult, err error) { @@ -662,7 +662,7 @@ func (suite *DebianSSHKeyPublicTestSuite) TestAddKey() { setupFS: func() {}, setupMock: func() { suite.mockExec.EXPECT(). - RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + RunPrivilegedCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). Return("", errors.New("permission denied")) }, validateFunc: func(result *user.SSHKeyResult, err error) { diff --git a/internal/provider/node/user/debian_user.go b/internal/provider/node/user/debian_user.go index d8b2c78d5..aa82fb684 100644 --- a/internal/provider/node/user/debian_user.go +++ b/internal/provider/node/user/debian_user.go @@ -111,7 +111,7 @@ func (d *Debian) CreateUser( args := d.buildUseraddArgs(opts) - _, err := d.execManager.RunCmd("useradd", args) + _, err := d.execManager.RunPrivilegedCmd("useradd", args) if err != nil { return nil, fmt.Errorf("user: useradd failed: %w", err) } @@ -148,7 +148,7 @@ func (d *Debian) UpdateUser( }, nil } - _, err := d.execManager.RunCmd("usermod", args) + _, err := d.execManager.RunPrivilegedCmd("usermod", args) if err != nil { return nil, fmt.Errorf("user: usermod failed: %w", err) } @@ -170,7 +170,7 @@ func (d *Debian) DeleteUser( ) (*Result, error) { _ = ctx - _, err := d.execManager.RunCmd("userdel", []string{"-r", name}) + _, err := d.execManager.RunPrivilegedCmd("userdel", []string{"-r", name}) if err != nil { return nil, fmt.Errorf("user: userdel failed: %w", err) } @@ -358,7 +358,7 @@ func (d *Debian) setPassword( ) error { input := fmt.Sprintf("%s:%s", name, password) - _, err := d.execManager.RunCmd( + _, err := d.execManager.RunPrivilegedCmd( "sh", []string{"-c", fmt.Sprintf("echo '%s' | chpasswd", input)}, ) From 7046548ec86dd6dda5c52965e902b385328a7083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 14:40:18 -0700 Subject: [PATCH 11/21] docs: add agent hardening feature page and config reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add agent-hardening.md feature page documenting least-privilege mode, sudoers drop-in, Linux capabilities, systemd unit, and preflight checks. Update configuration.md with privilege_escalation YAML block, env vars, and section reference table. Add Agent Hardening to the navbar dropdown. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/docs/sidebar/features/agent-hardening.md | 254 ++++++++++++++++++ docs/docs/sidebar/usage/configuration.md | 14 + docs/docusaurus.config.ts | 5 + 3 files changed, 273 insertions(+) create mode 100644 docs/docs/sidebar/features/agent-hardening.md diff --git a/docs/docs/sidebar/features/agent-hardening.md b/docs/docs/sidebar/features/agent-hardening.md new file mode 100644 index 000000000..5c713f69b --- /dev/null +++ b/docs/docs/sidebar/features/agent-hardening.md @@ -0,0 +1,254 @@ +--- +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. A preflight +check verifies the configuration at startup before the agent accepts 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: + # Prepend "sudo" to write commands. + sudo: false + # Verify Linux capabilities at startup. + capabilities: false + # Run privilege checks before accepting jobs. + preflight: false +``` + +| Field | Type | Default | Description | +| -------------- | ---- | ------- | ----------------------------------------- | +| `sudo` | bool | false | Prepend `sudo` to write commands | +| `capabilities` | bool | false | Verify Linux capabilities at startup | +| `preflight` | bool | false | Run privilege checks before accepting jobs | + +## 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.sudo: true`. When `sudo` is false, 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.capabilities: 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 `privilege_escalation.preflight: true`, the agent 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 --version` (or `sudo -n which +` 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. diff --git a/docs/docs/sidebar/usage/configuration.md b/docs/docs/sidebar/usage/configuration.md index 5697c6cf1..a7bb8d606 100644 --- a/docs/docs/sidebar/usage/configuration.md +++ b/docs/docs/sidebar/usage/configuration.md @@ -90,6 +90,9 @@ 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.sudo` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_SUDO` | +| `agent.privilege_escalation.capabilities` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_CAPABILITIES` | +| `agent.privilege_escalation.preflight` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_PREFLIGHT` | Environment variables take precedence over file values. @@ -504,6 +507,14 @@ agent: enabled: true # Port the metrics server listens on. port: 9091 + # Least-privilege mode for running the agent as an unprivileged user. + privilege_escalation: + # Prepend "sudo" to write commands. + sudo: false + # Verify Linux capabilities at startup. + capabilities: false + # Run privilege checks before accepting jobs. + preflight: false ``` ## Section Reference @@ -695,6 +706,9 @@ 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.sudo` | bool | Prepend sudo to write commands (default false) | +| `privilege_escalation.capabilities` | bool | Verify Linux capabilities at startup (default false) | +| `privilege_escalation.preflight` | bool | Run privilege checks before accepting jobs (default false) | When `metrics.enabled` is true, the port also serves `/health` (liveness) and `/health/ready` (readiness) probes without authentication. diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index f75efa81d..3ba2d6f71 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -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' } ] }, From db7cd83af1dd49c1d5a88ea081c6a81c6bd0f444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 14:42:32 -0700 Subject: [PATCH 12/21] chore(agent): fix lint errors in preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/agent/preflight.go | 2 +- internal/agent/preflight_public_test.go | 2 +- internal/exec/exec.go | 2 + internal/exec/manager.go | 7 + internal/exec/mocks/manager.gen.go | 15 +++ internal/exec/run_cmd_dir_public_test.go | 2 +- internal/exec/run_cmd_full_public_test.go | 2 +- internal/exec/run_cmd_public_test.go | 2 +- internal/exec/run_privileged_cmd.go | 35 +++++ .../exec/run_privileged_cmd_public_test.go | 122 ++++++++++++++++++ internal/exec/types.go | 1 + 11 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 internal/exec/run_privileged_cmd.go create mode 100644 internal/exec/run_privileged_cmd_public_test.go diff --git a/internal/agent/preflight.go b/internal/agent/preflight.go index a504f30d2..a04dd8848 100644 --- a/internal/agent/preflight.go +++ b/internal/agent/preflight.go @@ -197,7 +197,7 @@ func readCapEff() (uint64, error) { if err != nil { return 0, fmt.Errorf("open %s: %w", procStatusPath, err) } - defer f.Close() + defer func() { _ = f.Close() }() scanner := bufio.NewScanner(f) for scanner.Scan() { diff --git a/internal/agent/preflight_public_test.go b/internal/agent/preflight_public_test.go index cae2b7ac0..d0e8ae490 100644 --- a/internal/agent/preflight_public_test.go +++ b/internal/agent/preflight_public_test.go @@ -89,7 +89,7 @@ func (s *PreflightPublicTestSuite) TestCheckSudoAccess() { setupMock: func() { s.mockExecMgr.EXPECT(). RunCmd("sudo", gomock.Any()). - DoAndReturn(func(name string, args []string) (string, error) { + DoAndReturn(func(_ string, args []string) (string, error) { if len(args) == 3 && args[2] == "systemctl" { return "", fmt.Errorf("sudo: a password is required") } diff --git a/internal/exec/exec.go b/internal/exec/exec.go index f1931d725..f04e31439 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -33,9 +33,11 @@ const maxLogOutputLen = 200 // New factory to create a new Exec instance. func New( logger *slog.Logger, + sudo bool, ) *Exec { return &Exec{ logger: logger.With(slog.String("subsystem", "exec")), + sudo: sudo, } } diff --git a/internal/exec/manager.go b/internal/exec/manager.go index bd7722d05..486574a8e 100644 --- a/internal/exec/manager.go +++ b/internal/exec/manager.go @@ -29,6 +29,13 @@ type Manager interface { args []string, ) (string, error) + // RunPrivilegedCmd executes the provided command with arguments. + // When sudo is enabled, the command is prepended with "sudo". + RunPrivilegedCmd( + name string, + args []string, + ) (string, error) + // RunCmdFull executes a command with separate stdout/stderr capture, // an optional working directory, and a timeout in seconds. RunCmdFull( diff --git a/internal/exec/mocks/manager.gen.go b/internal/exec/mocks/manager.gen.go index 6f0224120..6a2045d7f 100644 --- a/internal/exec/mocks/manager.gen.go +++ b/internal/exec/mocks/manager.gen.go @@ -63,3 +63,18 @@ func (mr *MockManagerMockRecorder) RunCmdFull(name, args, cwd, timeout interface mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunCmdFull", reflect.TypeOf((*MockManager)(nil).RunCmdFull), name, args, cwd, timeout) } + +// RunPrivilegedCmd mocks base method. +func (m *MockManager) RunPrivilegedCmd(name string, args []string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RunPrivilegedCmd", name, args) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RunPrivilegedCmd indicates an expected call of RunPrivilegedCmd. +func (mr *MockManagerMockRecorder) RunPrivilegedCmd(name, args interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunPrivilegedCmd", reflect.TypeOf((*MockManager)(nil).RunPrivilegedCmd), name, args) +} diff --git a/internal/exec/run_cmd_dir_public_test.go b/internal/exec/run_cmd_dir_public_test.go index 30dd9fe59..dcfbd54b7 100644 --- a/internal/exec/run_cmd_dir_public_test.go +++ b/internal/exec/run_cmd_dir_public_test.go @@ -83,7 +83,7 @@ func (suite *RunCmdDirPublicTestSuite) TestRunCmd() { for _, tc := range tests { suite.Run(tc.name, func() { - em := exec.New(suite.logger) + em := exec.New(suite.logger, false) output, err := em.RunCmdInDir(tc.command, tc.args, tc.cwd) diff --git a/internal/exec/run_cmd_full_public_test.go b/internal/exec/run_cmd_full_public_test.go index 0467007ec..1cea5fe50 100644 --- a/internal/exec/run_cmd_full_public_test.go +++ b/internal/exec/run_cmd_full_public_test.go @@ -123,7 +123,7 @@ func (suite *RunCmdFullPublicTestSuite) TestRunCmdFull() { for _, tc := range tests { suite.Run(tc.name, func() { - em := exec.New(suite.logger) + em := exec.New(suite.logger, false) result, err := em.RunCmdFull(tc.command, tc.args, tc.cwd, tc.timeout) diff --git a/internal/exec/run_cmd_public_test.go b/internal/exec/run_cmd_public_test.go index d5c830d11..d1ded504e 100644 --- a/internal/exec/run_cmd_public_test.go +++ b/internal/exec/run_cmd_public_test.go @@ -79,7 +79,7 @@ func (suite *RunCmdPublicTestSuite) TestRunCmd() { for _, tc := range tests { suite.Run(tc.name, func() { - em := exec.New(suite.logger) + em := exec.New(suite.logger, false) output, err := em.RunCmd(tc.command, tc.args) diff --git a/internal/exec/run_privileged_cmd.go b/internal/exec/run_privileged_cmd.go new file mode 100644 index 000000000..421ebd908 --- /dev/null +++ b/internal/exec/run_privileged_cmd.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package exec + +// RunPrivilegedCmd executes the provided command with arguments. When sudo is +// enabled, the original command is passed as an argument to "sudo". +func (e *Exec) RunPrivilegedCmd( + name string, + args []string, +) (string, error) { + if e.sudo { + args = append([]string{name}, args...) + name = "sudo" + } + + return e.RunCmdImpl(name, args, "") +} diff --git a/internal/exec/run_privileged_cmd_public_test.go b/internal/exec/run_privileged_cmd_public_test.go new file mode 100644 index 000000000..94c180428 --- /dev/null +++ b/internal/exec/run_privileged_cmd_public_test.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package exec_test + +import ( + "log/slog" + "os" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/exec" +) + +type RunPrivilegedCmdPublicTestSuite struct { + suite.Suite + + logger *slog.Logger +} + +func (suite *RunPrivilegedCmdPublicTestSuite) SetupTest() { + suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) +} + +func (suite *RunPrivilegedCmdPublicTestSuite) TearDownTest() {} + +func (suite *RunPrivilegedCmdPublicTestSuite) TestRunPrivilegedCmd() { + tests := []struct { + name string + sudo bool + command string + args []string + expectError bool + validateFunc func(string, error) + }{ + { + name: "without sudo runs command directly", + sudo: false, + command: "echo", + args: []string{"-n", "hello"}, + expectError: false, + validateFunc: func(output string, _ error) { + suite.Require().Equal("hello", output) + }, + }, + { + name: "without sudo invalid command returns error", + sudo: false, + command: "nonexistent-command-xyz", + args: []string{}, + expectError: true, + validateFunc: func(_ string, err error) { + suite.Require().Contains(err.Error(), "not found") + }, + }, + { + name: "with sudo prepends sudo to command", + sudo: true, + command: "nonexistent-command-xyz", + args: []string{"arg1"}, + expectError: true, + validateFunc: func(_ string, err error) { + // When sudo=true, the exec manager prepends "sudo" to the + // command. The error varies by environment (sudo may not be + // installed, or may prompt for a password), but the attempt + // to run sudo confirms the prepend behavior. + suite.Require().Error(err) + }, + }, + { + name: "with sudo and no args", + sudo: true, + command: "nonexistent-command-xyz", + args: []string{}, + expectError: true, + validateFunc: func(_ string, err error) { + suite.Require().Error(err) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + em := exec.New(suite.logger, tc.sudo) + + output, err := em.RunPrivilegedCmd(tc.command, tc.args) + + if tc.expectError { + suite.Require().Error(err) + } else { + suite.Require().NoError(err) + } + if tc.validateFunc != nil { + tc.validateFunc(output, err) + } + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestRunPrivilegedCmdPublicTestSuite(t *testing.T) { + suite.Run(t, new(RunPrivilegedCmdPublicTestSuite)) +} diff --git a/internal/exec/types.go b/internal/exec/types.go index 168c0d34a..347c73320 100644 --- a/internal/exec/types.go +++ b/internal/exec/types.go @@ -27,6 +27,7 @@ import ( // Exec disk implementation. type Exec struct { logger *slog.Logger + sudo bool } // CmdResult contains the full result of a command execution From 8ab74d1680667c38e5071f9dd44b4922d092646e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 14:46:55 -0700 Subject: [PATCH 13/21] chore(config): add privilege_escalation to dev config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- configs/osapi.dev.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/configs/osapi.dev.yaml b/configs/osapi.dev.yaml index 24a2be6b6..0802207ad 100644 --- a/configs/osapi.dev.yaml +++ b/configs/osapi.dev.yaml @@ -48,6 +48,12 @@ agent: metrics: enabled: true port: 9091 + # Least-privilege mode. When enabled, the agent runs as an + # unprivileged user and uses sudo for write operations. + # privilege_escalation: + # sudo: false + # capabilities: false + # preflight: false nats: stream: From 0875a257b430be18c47e478bc739fcbe38ec24aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 14:48:34 -0700 Subject: [PATCH 14/21] chore(config): uncomment privilege_escalation, enable preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uncomment privilege_escalation in all config files with preflight enabled by default. Add to integration test config with preflight disabled (tests run without sudo/caps). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- configs/osapi.dev.yaml | 12 ++++++------ configs/osapi.yaml | 12 ++++++------ test/integration/osapi.yaml | 4 ++++ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/configs/osapi.dev.yaml b/configs/osapi.dev.yaml index 0802207ad..0d02b4090 100644 --- a/configs/osapi.dev.yaml +++ b/configs/osapi.dev.yaml @@ -48,12 +48,12 @@ agent: metrics: enabled: true port: 9091 - # Least-privilege mode. When enabled, the agent runs as an - # unprivileged user and uses sudo for write operations. - # privilege_escalation: - # sudo: false - # capabilities: false - # preflight: false + # Least-privilege mode for running the agent as an + # unprivileged user with sudo for write operations. + privilege_escalation: + sudo: false + capabilities: false + preflight: true nats: stream: diff --git a/configs/osapi.yaml b/configs/osapi.yaml index c299e29e2..1d51e3865 100644 --- a/configs/osapi.yaml +++ b/configs/osapi.yaml @@ -171,10 +171,10 @@ agent: enabled: true host: '0.0.0.0' port: 9091 - # Least-privilege mode. When enabled, the agent runs as an - # unprivileged user and uses sudo for write operations. - # privilege_escalation: - # sudo: false - # capabilities: false - # preflight: false + # Least-privilege mode for running the agent as an + # unprivileged user with sudo for write operations. + privilege_escalation: + sudo: false + capabilities: false + preflight: true diff --git a/test/integration/osapi.yaml b/test/integration/osapi.yaml index dfc4e3ece..f1c24045f 100644 --- a/test/integration/osapi.yaml +++ b/test/integration/osapi.yaml @@ -118,4 +118,8 @@ agent: max_jobs: 10 metrics: enabled: false + privilege_escalation: + sudo: false + capabilities: false + preflight: false From 3ec659f4f6c7a217a815a685d232b2d8ee33a3e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 14:55:15 -0700 Subject: [PATCH 15/21] refactor(agent): remove preflight config, verify automatically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When sudo or capabilities is enabled, the agent now automatically runs preflight verification at startup. The separate preflight config field is no longer needed. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- configs/osapi.dev.yaml | 5 ++-- configs/osapi.yaml | 5 ++-- docs/docs/sidebar/features/agent-hardening.md | 25 ++++++++----------- docs/docs/sidebar/usage/configuration.md | 4 --- internal/agent/preflight_public_test.go | 10 -------- internal/agent/server.go | 4 +-- internal/config/types.go | 2 -- test/integration/osapi.yaml | 1 - 8 files changed, 17 insertions(+), 39 deletions(-) diff --git a/configs/osapi.dev.yaml b/configs/osapi.dev.yaml index 0d02b4090..97e72a3aa 100644 --- a/configs/osapi.dev.yaml +++ b/configs/osapi.dev.yaml @@ -48,12 +48,11 @@ agent: metrics: enabled: true port: 9091 - # Least-privilege mode for running the agent as an - # unprivileged user with sudo for write operations. + # Least-privilege mode. Use sudo for write commands and verify + # Linux capabilities at startup. See Agent Hardening docs. privilege_escalation: sudo: false capabilities: false - preflight: true nats: stream: diff --git a/configs/osapi.yaml b/configs/osapi.yaml index 1d51e3865..f71d8eb3f 100644 --- a/configs/osapi.yaml +++ b/configs/osapi.yaml @@ -171,10 +171,9 @@ agent: enabled: true host: '0.0.0.0' port: 9091 - # Least-privilege mode for running the agent as an - # unprivileged user with sudo for write operations. + # Least-privilege mode. Use sudo for write commands and verify + # Linux capabilities at startup. See Agent Hardening docs. privilege_escalation: sudo: false capabilities: false - preflight: true diff --git a/docs/docs/sidebar/features/agent-hardening.md b/docs/docs/sidebar/features/agent-hardening.md index 5c713f69b..4dea78cb9 100644 --- a/docs/docs/sidebar/features/agent-hardening.md +++ b/docs/docs/sidebar/features/agent-hardening.md @@ -8,9 +8,9 @@ sidebar_label: 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. A preflight -check verifies the configuration at startup before the agent accepts any -jobs. +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. @@ -24,15 +24,12 @@ agent: sudo: false # Verify Linux capabilities at startup. capabilities: false - # Run privilege checks before accepting jobs. - preflight: false ``` -| Field | Type | Default | Description | -| -------------- | ---- | ------- | ----------------------------------------- | -| `sudo` | bool | false | Prepend `sudo` to write commands | -| `capabilities` | bool | false | Verify Linux capabilities at startup | -| `preflight` | bool | false | Run privilege checks before accepting jobs | +| Field | Type | Default | Description | +| -------------- | ---- | ------- | ------------------------------------ | +| `sudo` | bool | false | Prepend `sudo` to write commands | +| `capabilities` | bool | false | Verify Linux capabilities at startup | ## How It Works @@ -168,10 +165,10 @@ that `sudo` (if also used) can elevate correctly. ## Preflight Checks -When `privilege_escalation.preflight: true`, the agent 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. +When `sudo` or `capabilities` is enabled, 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 --version` (or `sudo -n which ` for commands that do not support `--version`). The `-n` flag diff --git a/docs/docs/sidebar/usage/configuration.md b/docs/docs/sidebar/usage/configuration.md index a7bb8d606..279db2d7b 100644 --- a/docs/docs/sidebar/usage/configuration.md +++ b/docs/docs/sidebar/usage/configuration.md @@ -92,7 +92,6 @@ uppercased: | `agent.metrics.port` | `OSAPI_AGENT_METRICS_PORT` | | `agent.privilege_escalation.sudo` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_SUDO` | | `agent.privilege_escalation.capabilities` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_CAPABILITIES` | -| `agent.privilege_escalation.preflight` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_PREFLIGHT` | Environment variables take precedence over file values. @@ -513,8 +512,6 @@ agent: sudo: false # Verify Linux capabilities at startup. capabilities: false - # Run privilege checks before accepting jobs. - preflight: false ``` ## Section Reference @@ -708,7 +705,6 @@ When enabled, the port also serves `/health` (liveness) and `/health/ready` | `metrics.port` | int | Port the metrics server listens on (default: 9091) | | `privilege_escalation.sudo` | bool | Prepend sudo to write commands (default false) | | `privilege_escalation.capabilities` | bool | Verify Linux capabilities at startup (default false) | -| `privilege_escalation.preflight` | bool | Run privilege checks before accepting jobs (default false) | When `metrics.enabled` is true, the port also serves `/health` (liveness) and `/health/ready` (readiness) probes without authentication. diff --git a/internal/agent/preflight_public_test.go b/internal/agent/preflight_public_test.go index d0e8ae490..9e0a502ca 100644 --- a/internal/agent/preflight_public_test.go +++ b/internal/agent/preflight_public_test.go @@ -247,16 +247,6 @@ func (s *PreflightPublicTestSuite) TestRunPreflight() { s.NotEmpty(results) }, }, - { - name: "when both disabled", - checkSudo: false, - checkCaps: false, - setup: func() {}, - validateFunc: func(results []agent.PreflightResult, allPassed bool) { - s.True(allPassed) - s.Empty(results) - }, - }, } for _, tc := range tests { diff --git a/internal/agent/server.go b/internal/agent/server.go index 43166c250..bc09250b6 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -47,9 +47,9 @@ func (a *Agent) Start() { slog.Any("labels", a.appConfig.Agent.Labels), ) - // Run preflight checks if configured. + // Run preflight checks when privilege escalation is enabled. pe := a.appConfig.Agent.PrivilegeEscalation - if pe.Preflight { + if pe.Sudo || pe.Capabilities { results, ok := RunPreflight(a.logger, a.execManager, pe.Sudo, pe.Capabilities) if !ok { for _, r := range results { diff --git a/internal/config/types.go b/internal/config/types.go index f49bec6bd..d29cd18e8 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -356,8 +356,6 @@ type PrivilegeEscalation struct { Sudo bool `mapstructure:"sudo"` // Capabilities verifies Linux capabilities at startup when true. Capabilities bool `mapstructure:"capabilities"` - // Preflight runs privilege checks before accepting jobs when true. - Preflight bool `mapstructure:"preflight"` } // AgentConfig configuration settings. diff --git a/test/integration/osapi.yaml b/test/integration/osapi.yaml index f1c24045f..b6add11a9 100644 --- a/test/integration/osapi.yaml +++ b/test/integration/osapi.yaml @@ -121,5 +121,4 @@ agent: privilege_escalation: sudo: false capabilities: false - preflight: false From 6914cec8f20a309d3e90f5e8ac4f229f66652de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 15:07:15 -0700 Subject: [PATCH 16/21] refactor(agent): simplify privilege escalation to single enabled field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace sudo + capabilities booleans with a single enabled toggle. When enabled, both sudo and capability verification are active. Update all configs, preflight, docs, and spec. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/agent_setup.go | 2 +- configs/osapi.dev.yaml | 7 +- configs/osapi.yaml | 7 +- docs/docs/sidebar/features/agent-hardening.md | 99 +++++----- docs/docs/sidebar/usage/configuration.md | 14 +- ...04-02-agent-privilege-escalation-design.md | 42 ++--- .../2026-04-02-agent-privilege-escalation.md | 175 +++++++++++------- internal/agent/preflight.go | 34 ++-- internal/agent/preflight_public_test.go | 27 +-- internal/agent/server.go | 4 +- internal/config/types.go | 9 +- test/integration/osapi.yaml | 3 +- 12 files changed, 221 insertions(+), 202 deletions(-) diff --git a/cmd/agent_setup.go b/cmd/agent_setup.go index 4ca81dca8..ae7653c0e 100644 --- a/cmd/agent_setup.go +++ b/cmd/agent_setup.go @@ -82,7 +82,7 @@ func setupAgent( log.Info("running on darwin") } - execManager := exec.New(log, appConfig.Agent.PrivilegeEscalation.Sudo) + execManager := exec.New(log, appConfig.Agent.PrivilegeEscalation.Enabled) // --- Node providers --- var hostProvider nodeHost.Provider diff --git a/configs/osapi.dev.yaml b/configs/osapi.dev.yaml index 97e72a3aa..c745c4e0a 100644 --- a/configs/osapi.dev.yaml +++ b/configs/osapi.dev.yaml @@ -48,11 +48,10 @@ agent: metrics: enabled: true port: 9091 - # Least-privilege mode. Use sudo for write commands and verify - # Linux capabilities at startup. See Agent Hardening docs. + # Least-privilege mode. When enabled, write commands use sudo and + # Linux capabilities are verified at startup. See Agent Hardening docs. privilege_escalation: - sudo: false - capabilities: false + enabled: false nats: stream: diff --git a/configs/osapi.yaml b/configs/osapi.yaml index f71d8eb3f..49ba886c7 100644 --- a/configs/osapi.yaml +++ b/configs/osapi.yaml @@ -171,9 +171,8 @@ agent: enabled: true host: '0.0.0.0' port: 9091 - # Least-privilege mode. Use sudo for write commands and verify - # Linux capabilities at startup. See Agent Hardening docs. + # Least-privilege mode. When enabled, write commands use sudo and + # Linux capabilities are verified at startup. See Agent Hardening docs. privilege_escalation: - sudo: false - capabilities: false + enabled: false diff --git a/docs/docs/sidebar/features/agent-hardening.md b/docs/docs/sidebar/features/agent-hardening.md index 4dea78cb9..4df4f55ae 100644 --- a/docs/docs/sidebar/features/agent-hardening.md +++ b/docs/docs/sidebar/features/agent-hardening.md @@ -6,45 +6,43 @@ 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. +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. +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: - # Prepend "sudo" to write commands. - sudo: false - # Verify Linux capabilities at startup. - capabilities: false + # Activate least-privilege mode: sudo for write commands + # and capability verification at startup. + enabled: false ``` -| Field | Type | Default | Description | -| -------------- | ---- | ------- | ------------------------------------ | -| `sudo` | bool | false | Prepend `sudo` to write commands | -| `capabilities` | bool | false | Verify Linux capabilities at startup | +| 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.). +- **`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.sudo: true`. When `sudo` is false, this is - identical to `RunCmd`. + `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. +providers themselves have no knowledge of whether `sudo` is enabled — the exec +manager handles it transparently. ```go // Read — always unprivileged @@ -57,8 +55,8 @@ _, err := d.execManager.RunPrivilegedCmd( ## 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: +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 @@ -104,8 +102,8 @@ osapi ALL=(root) NOPASSWD: /usr/sbin/update-ca-certificates osapi ALL=(root) NOPASSWD: /sbin/shutdown * ``` -Validate the file with `sudo visudo -c -f /etc/sudoers.d/osapi-agent` -before reloading. +Validate the file with `sudo visudo -c -f /etc/sudoers.d/osapi-agent` before +reloading. ## Linux Capabilities @@ -118,9 +116,9 @@ sudo setcap \ /usr/local/bin/osapi ``` -When `privilege_escalation.capabilities: true`, the agent reads -`/proc/self/status` at startup and checks the `CapEff` bitmask for the -required bits: +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 | | --------------------- | --- | ------------------------------- | @@ -129,13 +127,13 @@ required bits: | `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. +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: +The recommended way to run the agent as an unprivileged user with capabilities +preserved across restarts: ```ini [Unit] @@ -159,21 +157,21 @@ PrivateTmp=true 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. +`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 `sudo` or `capabilities` is enabled, 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. +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 --version` (or `sudo -n which -` 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. +The sudo check runs `sudo -n --version` (or `sudo -n which ` +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: @@ -242,10 +240,9 @@ Result: FAILED (1 error) ## 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. +- **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. diff --git a/docs/docs/sidebar/usage/configuration.md b/docs/docs/sidebar/usage/configuration.md index 279db2d7b..88a8dc162 100644 --- a/docs/docs/sidebar/usage/configuration.md +++ b/docs/docs/sidebar/usage/configuration.md @@ -90,8 +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.sudo` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_SUDO` | -| `agent.privilege_escalation.capabilities` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_CAPABILITIES` | +| `agent.privilege_escalation.enabled` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_ENABLED` | Environment variables take precedence over file values. @@ -506,12 +505,10 @@ agent: enabled: true # Port the metrics server listens on. port: 9091 - # Least-privilege mode for running the agent as an unprivileged user. + # Least-privilege mode. When enabled, write commands use sudo and + # Linux capabilities are verified at startup. privilege_escalation: - # Prepend "sudo" to write commands. - sudo: false - # Verify Linux capabilities at startup. - capabilities: false + enabled: false ``` ## Section Reference @@ -703,8 +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.sudo` | bool | Prepend sudo to write commands (default false) | -| `privilege_escalation.capabilities` | bool | Verify Linux capabilities at startup (default false) | +| `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. diff --git a/docs/plans/2026-04-02-agent-privilege-escalation-design.md b/docs/plans/2026-04-02-agent-privilege-escalation-design.md index f4467df04..4f3a1f589 100644 --- a/docs/plans/2026-04-02-agent-privilege-escalation-design.md +++ b/docs/plans/2026-04-02-agent-privilege-escalation-design.md @@ -28,11 +28,11 @@ agent: preflight: true ``` -| Field | Type | Default | Description | -| -------------- | ---- | ------- | ---------------------------------------- | -| `sudo` | bool | false | Prepend `sudo` to write commands | -| `capabilities` | bool | false | Verify Linux capabilities at startup | -| `preflight` | bool | false | Run privilege checks before accepting jobs| +| Field | Type | Default | Description | +| -------------- | ---- | ------- | ------------------------------------------ | +| `sudo` | bool | false | Prepend `sudo` to write commands | +| `capabilities` | bool | false | Verify Linux capabilities at startup | +| `preflight` | bool | false | Run privilege checks before accepting jobs | When all fields are false (or the section is absent), the agent behaves as before — commands run as the current user. @@ -68,9 +68,9 @@ When `sudo` is false, `RunPrivilegedCmd` is identical to `RunCmd`. ## Provider Changes -Every provider write operation changes from `RunCmd` to `RunPrivilegedCmd`. -Read operations stay on `RunCmd`. The providers themselves don't know or care -whether sudo is enabled — the exec manager handles it. +Every provider write operation changes from `RunCmd` to `RunPrivilegedCmd`. Read +operations stay on `RunCmd`. The providers themselves don't know or care whether +sudo is enabled — the exec manager handles it. ```go // Read — always unprivileged @@ -92,8 +92,8 @@ calls `RunCmd` instead of `RunPrivilegedCmd`, the mock expectation fails. | `systemctl start/stop/…` | Service | | `systemctl daemon-reload` | Service | | `sysctl -p`, `--system` | Sysctl | -| `timedatectl set-timezone` | Timezone | -| `hostnamectl set-hostname` | Hostname | +| `timedatectl set-timezone` | Timezone | +| `hostnamectl set-hostname` | Hostname | | `chronyc reload sources` | NTP | | `useradd`, `usermod` | User | | `userdel -r` | User | @@ -137,20 +137,20 @@ the failure and exits with a non-zero status. For each write command, run `sudo -n --version` (or equivalent no-op flag). The `-n` flag makes sudo fail immediately if a password would be -required. If the command doesn't support `--version`, use `sudo -n which -` as a fallback. +required. If the command doesn't support `--version`, use +`sudo -n which ` as a fallback. ### Capability verification -Read `/proc/self/status`, parse the `CapEff` hexadecimal bitmask, and check -that required capability bits are set: +Read `/proc/self/status`, parse the `CapEff` hexadecimal bitmask, and check that +required capability bits are set: -| Capability | Bit | Purpose | -| --------------------- | --- | ---------------------------- | -| `CAP_DAC_READ_SEARCH` | 2 | Read restricted files | +| 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 | +| `CAP_FOWNER` | 3 | Change file ownership | +| `CAP_KILL` | 5 | Signal any process | ### Output format @@ -274,8 +274,8 @@ WantedBy=multi-user.target - `internal/agent/preflight_public_test.go` — tests - `internal/agent/agent.go` — call preflight during `Start()` - `cmd/agent_setup.go` — pass `sudo` bool to exec manager -- Every provider `debian*.go` file — change write `RunCmd` to - `RunPrivilegedCmd` (~37 call sites) +- Every provider `debian*.go` file — change write `RunCmd` to `RunPrivilegedCmd` + (~37 call sites) - Every provider `debian*_public_test.go` — update mock expectations - `docs/docs/sidebar/usage/configuration.md` — add config reference - `docs/docs/sidebar/features/` — add agent hardening feature page diff --git a/docs/plans/2026-04-02-agent-privilege-escalation.md b/docs/plans/2026-04-02-agent-privilege-escalation.md index cfe81c67b..7531ceb34 100644 --- a/docs/plans/2026-04-02-agent-privilege-escalation.md +++ b/docs/plans/2026-04-02-agent-privilege-escalation.md @@ -2,17 +2,17 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use > superpowers:subagent-driven-development (recommended) or -> superpowers:executing-plans to implement this plan task-by-task. Steps -> use checkbox (`- [ ]`) syntax for tracking. +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add config-driven sudo escalation for write commands, Linux -capability verification, and startup preflight checks to the OSAPI -agent so it can run as an unprivileged user. +**Goal:** Add config-driven sudo escalation for write commands, Linux capability +verification, and startup preflight checks to the OSAPI agent so it can run as +an unprivileged user. -**Architecture:** The exec `Manager` interface gains `RunPrivilegedCmd` -which prepends `sudo` when configured. Providers call it for write -operations. At startup, the agent runs preflight checks to verify -sudo and capabilities before accepting jobs. +**Architecture:** The exec `Manager` interface gains `RunPrivilegedCmd` which +prepends `sudo` when configured. Providers call it for write operations. At +startup, the agent runs preflight checks to verify sudo and capabilities before +accepting jobs. **Tech Stack:** Go, Linux capabilities (`/proc/self/status`), sudo @@ -21,6 +21,7 @@ sudo and capabilities before accepting jobs. ### Task 1: Add privilege escalation config **Files:** + - Modify: `internal/config/types.go:353-374` - Modify: `configs/osapi.yaml` - Modify: `configs/osapi.nerd.yaml` @@ -55,16 +56,15 @@ type AgentConfig struct { - [ ] **Step 2: Add config to YAML files** In `configs/osapi.yaml`, `configs/osapi.nerd.yaml`, and -`configs/osapi.local.yaml`, add to the `agent:` section (disabled by -default): +`configs/osapi.local.yaml`, add to the `agent:` section (disabled by default): ```yaml - # Least-privilege mode. When enabled, the agent runs as an - # unprivileged user and uses sudo for write operations. - # privilege_escalation: - # sudo: false - # capabilities: false - # preflight: false +# Least-privilege mode. When enabled, the agent runs as an +# unprivileged user and uses sudo for write operations. +# privilege_escalation: +# sudo: false +# capabilities: false +# preflight: false ``` - [ ] **Step 3: Verify build** @@ -82,6 +82,7 @@ feat(agent): add privilege escalation config ### Task 2: Add RunPrivilegedCmd to exec manager **Files:** + - Modify: `internal/exec/manager.go` - Modify: `internal/exec/types.go` - Modify: `internal/exec/exec.go` @@ -174,15 +175,14 @@ func (e *Exec) RunPrivilegedCmd( Create `internal/exec/run_privileged_cmd_public_test.go`. Test: -- When sudo is false, RunPrivilegedCmd behaves like RunCmd (runs - the command directly) -- When sudo is true, the command is prefixed with sudo (verify - the actual args passed to the underlying exec) +- When sudo is false, RunPrivilegedCmd behaves like RunCmd (runs the command + directly) +- When sudo is true, the command is prefixed with sudo (verify the actual args + passed to the underlying exec) -Since `RunCmdImpl` calls `os/exec.Command`, use a test helper -pattern: override the command execution to capture what would be -run. Look at how `run_cmd_public_test.go` tests `RunCmd` and follow -the same pattern. +Since `RunCmdImpl` calls `os/exec.Command`, use a test helper pattern: override +the command execution to capture what would be run. Look at how +`run_cmd_public_test.go` tests `RunCmd` and follow the same pattern. - [ ] **Step 5: Update agent_setup.go to pass sudo config** @@ -205,14 +205,13 @@ Run: `go generate ./internal/exec/mocks/...` - [ ] **Step 7: Fix compilation errors** -The mock `Manager` now requires `RunPrivilegedCmd`. Any existing test -that constructs a mock `Manager` may need updating. Run -`go build ./...` and fix any compile errors. +The mock `Manager` now requires `RunPrivilegedCmd`. Any existing test that +constructs a mock `Manager` may need updating. Run `go build ./...` and fix any +compile errors. - [ ] **Step 8: Run tests** -Run: `go test ./internal/exec/... -count=1` -Run: `go build ./...` +Run: `go test ./internal/exec/... -count=1` Run: `go build ./...` - [ ] **Step 9: Commit** @@ -225,6 +224,7 @@ feat(exec): add RunPrivilegedCmd with config-driven sudo ### Task 3: Add preflight checks **Files:** + - Create: `internal/agent/preflight.go` - Create: `internal/agent/preflight_public_test.go` - Modify: `internal/agent/server.go` @@ -437,28 +437,31 @@ func readCapEff() (uint64, error) { - [ ] **Step 2: Write preflight tests** -Create `internal/agent/preflight_public_test.go`. Use testify/suite -with table-driven tests. Test: +Create `internal/agent/preflight_public_test.go`. Use testify/suite with +table-driven tests. Test: **TestCheckSudoAccess:** + - All commands pass (mock RunCmd returns nil for all sudo -n which calls) - One command fails (mock RunCmd returns error for one) - Multiple commands fail **TestCheckCapabilities:** -- All capabilities present (write a fake /proc/self/status file with - full CapEff, override `procStatusPath` via export_test.go) + +- All capabilities present (write a fake /proc/self/status file with full + CapEff, override `procStatusPath` via export_test.go) - Missing capability (write CapEff without a required bit) - Cannot read file (set procStatusPath to nonexistent path) **TestRunPreflight:** + - Both sudo and caps enabled and pass → allPassed true - Sudo fails → allPassed false - Caps fails → allPassed false - Both disabled → empty results, allPassed true -Create `internal/agent/export_test.go` (or add to existing) to -expose `procStatusPath` for testing: +Create `internal/agent/export_test.go` (or add to existing) to expose +`procStatusPath` for testing: ```go package agent @@ -469,8 +472,8 @@ func ResetProcStatusPath() { procStatusPath = "/proc/self/status" } - [ ] **Step 3: Wire preflight into agent Start()** -In `internal/agent/server.go`, add preflight check after hostname -determination but before starting heartbeat: +In `internal/agent/server.go`, add preflight check after hostname determination +but before starting heartbeat: ```go func (a *Agent) Start() { @@ -511,14 +514,13 @@ func (a *Agent) Start() { // ... rest of Start() unchanged ... ``` -The agent needs access to the exec manager. Check if it's already on -the `Agent` struct. If not, add an `execManager exec.Manager` field -and pass it from `agent_setup.go`. +The agent needs access to the exec manager. Check if it's already on the `Agent` +struct. If not, add an `execManager exec.Manager` field and pass it from +`agent_setup.go`. - [ ] **Step 4: Run tests** -Run: `go test ./internal/agent/... -count=1` -Run: `go build ./...` +Run: `go test ./internal/agent/... -count=1` Run: `go build ./...` - [ ] **Step 5: Commit** @@ -531,6 +533,7 @@ feat(agent): add preflight checks for sudo and capabilities ### Task 4: Migrate service provider to RunPrivilegedCmd **Files:** + - Modify: `internal/provider/node/service/debian_action.go` - Modify: `internal/provider/node/service/debian_unit.go` - Modify: `internal/provider/node/service/debian_action_public_test.go` @@ -558,6 +561,7 @@ d.execManager.RunPrivilegedCmd("systemctl", []string{"disable", unitName}) ``` Keep these as `RunCmd` (reads): + - `systemctl is-active` (Start, Stop) - `systemctl is-enabled` (Enable, Disable) @@ -574,13 +578,13 @@ d.execManager.RunPrivilegedCmd("systemctl", []string{"daemon-reload"}) - [ ] **Step 3: Update test mock expectations** -In `debian_action_public_test.go`, change all mock expectations for -write commands from `RunCmd` to `RunPrivilegedCmd`. Keep read command -expectations on `RunCmd`. +In `debian_action_public_test.go`, change all mock expectations for write +commands from `RunCmd` to `RunPrivilegedCmd`. Keep read command expectations on +`RunCmd`. -In `debian_unit_public_test.go`, change mock expectations for -`systemctl stop`, `systemctl disable`, `systemctl daemon-reload` -from `RunCmd` to `RunPrivilegedCmd`. +In `debian_unit_public_test.go`, change mock expectations for `systemctl stop`, +`systemctl disable`, `systemctl daemon-reload` from `RunCmd` to +`RunPrivilegedCmd`. - [ ] **Step 4: Run tests** @@ -597,16 +601,19 @@ refactor(service): use RunPrivilegedCmd for write operations ### Task 5: Migrate sysctl provider **Files:** + - Modify: `internal/provider/node/sysctl/debian.go` - Modify: `internal/provider/node/sysctl/debian_public_test.go` - [ ] **Step 1: Update write calls** Change to `RunPrivilegedCmd`: + - `sysctl -p ` (apply config) - `sysctl --system` (reload all) Keep as `RunCmd`: + - `sysctl -n ` (read parameter) - [ ] **Step 2: Update test mock expectations** @@ -626,15 +633,18 @@ refactor(sysctl): use RunPrivilegedCmd for write operations ### Task 6: Migrate hostname provider **Files:** + - Modify: `internal/provider/node/host/debian_update_hostname.go` - Modify: `internal/provider/node/host/debian_update_hostname_public_test.go` - [ ] **Step 1: Update write calls** Change to `RunPrivilegedCmd`: + - `hostnamectl set-hostname ` Keep as `RunCmd` (in other host files): + - `hostnamectl hostname` (read) - [ ] **Step 2: Update test mock expectations** @@ -654,15 +664,18 @@ refactor(host): use RunPrivilegedCmd for write operations ### Task 7: Migrate timezone provider **Files:** + - Modify: `internal/provider/node/timezone/debian.go` - Modify: `internal/provider/node/timezone/debian_public_test.go` - [ ] **Step 1: Update write calls** Change to `RunPrivilegedCmd`: + - `timedatectl set-timezone ` Keep as `RunCmd`: + - `timedatectl show -p Timezone --value` (read) - `date +%:z` (read) @@ -683,15 +696,18 @@ refactor(timezone): use RunPrivilegedCmd for write operations ### Task 8: Migrate NTP provider **Files:** + - Modify: `internal/provider/node/ntp/debian.go` - Modify: `internal/provider/node/ntp/debian_public_test.go` - [ ] **Step 1: Update write calls** Change to `RunPrivilegedCmd`: + - `chronyc reload sources` Keep as `RunCmd`: + - `chronyc tracking` (read) - `chronyc sources -c` (read) @@ -712,6 +728,7 @@ refactor(ntp): use RunPrivilegedCmd for write operations ### Task 9: Migrate user provider **Files:** + - Modify: `internal/provider/node/user/debian_user.go` - Modify: `internal/provider/node/user/debian_group.go` - Modify: `internal/provider/node/user/debian_ssh_key.go` @@ -722,18 +739,21 @@ refactor(ntp): use RunPrivilegedCmd for write operations - [ ] **Step 1: Update write calls in debian_user.go** Change to `RunPrivilegedCmd`: + - `useradd --create-home ...` - `usermod ...` (all variants) - `userdel -r ...` - `sh -c "echo ... | chpasswd"` Keep as `RunCmd`: + - `id -Gn ` (read) - `passwd -S ` (read) - [ ] **Step 2: Update write calls in debian_group.go** Change to `RunPrivilegedCmd`: + - `groupadd ...` - `groupdel ...` - `gpasswd -M ...` @@ -741,6 +761,7 @@ Change to `RunPrivilegedCmd`: - [ ] **Step 3: Update write calls in debian_ssh_key.go** Change to `RunPrivilegedCmd`: + - `chown -R ...` - [ ] **Step 4: Update all test mock expectations** @@ -760,17 +781,20 @@ refactor(user): use RunPrivilegedCmd for write operations ### Task 10: Migrate package provider **Files:** + - Modify: `internal/provider/node/apt/debian.go` - Modify: `internal/provider/node/apt/debian_public_test.go` - [ ] **Step 1: Update write calls** Change to `RunPrivilegedCmd`: + - `apt-get install -y ...` - `apt-get remove -y ...` - `apt-get update` Keep as `RunCmd`: + - `dpkg-query ...` (read) - `apt list --upgradable` (read) @@ -791,12 +815,14 @@ refactor(apt): use RunPrivilegedCmd for write operations ### Task 11: Migrate power provider **Files:** + - Modify: `internal/provider/node/power/debian.go` - Modify: `internal/provider/node/power/debian_public_test.go` - [ ] **Step 1: Update write calls** Change to `RunPrivilegedCmd`: + - `shutdown -r ...` (reboot) - `shutdown -h ...` (halt) @@ -817,12 +843,14 @@ refactor(power): use RunPrivilegedCmd for write operations ### Task 12: Migrate certificate provider **Files:** + - Modify: `internal/provider/node/certificate/debian.go` - Modify: `internal/provider/node/certificate/debian_public_test.go` - [ ] **Step 1: Update write calls** Change to `RunPrivilegedCmd`: + - `update-ca-certificates` - [ ] **Step 2: Update test mock expectations** @@ -842,17 +870,18 @@ refactor(certificate): use RunPrivilegedCmd for write operations ### Task 13: Migrate DNS provider **Files:** -- Modify: `internal/provider/network/dns/debian_update_resolv_conf_by_interface.go` - (or equivalent write file) + +- Modify: + `internal/provider/network/dns/debian_update_resolv_conf_by_interface.go` (or + equivalent write file) - Modify: corresponding test file - [ ] **Step 1: Check if DNS uses exec for writes** -Read the DNS provider to determine if it executes commands for -writes. The DNS provider may use `resolvectl` or write files -directly. If it uses `RunCmd` for writes, change to -`RunPrivilegedCmd`. If it only writes files via `avfs`, no changes -needed (file writes are covered by capabilities). +Read the DNS provider to determine if it executes commands for writes. The DNS +provider may use `resolvectl` or write files directly. If it uses `RunCmd` for +writes, change to `RunPrivilegedCmd`. If it only writes files via `avfs`, no +changes needed (file writes are covered by capabilities). - [ ] **Step 2: Update if needed and run tests** @@ -869,6 +898,7 @@ refactor(dns): use RunPrivilegedCmd for write operations ### Task 14: Update documentation **Files:** + - Modify: `docs/docs/sidebar/usage/configuration.md` - Create: `docs/docs/sidebar/features/agent-hardening.md` @@ -877,27 +907,28 @@ refactor(dns): use RunPrivilegedCmd for write operations In the agent section of configuration.md, add: ```yaml - # Least-privilege mode for running the agent as an unprivileged user. - privilege_escalation: - # Prepend "sudo" to write commands. - sudo: false - # Verify Linux capabilities at startup. - capabilities: false - # Run privilege checks before accepting jobs. - preflight: false +# Least-privilege mode for running the agent as an unprivileged user. +privilege_escalation: + # Prepend "sudo" to write commands. + sudo: false + # Verify Linux capabilities at startup. + capabilities: false + # Run privilege checks before accepting jobs. + preflight: false ``` Add the environment variable mappings: -| Config Key | Environment Variable | -|---|---| -| `agent.privilege_escalation.sudo` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_SUDO` | +| Config Key | Environment Variable | +| ----------------------------------------- | ----------------------------------------------- | +| `agent.privilege_escalation.sudo` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_SUDO` | | `agent.privilege_escalation.capabilities` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_CAPABILITIES` | -| `agent.privilege_escalation.preflight` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_PREFLIGHT` | +| `agent.privilege_escalation.preflight` | `OSAPI_AGENT_PRIVILEGE_ESCALATION_PREFLIGHT` | - [ ] **Step 2: Create agent hardening feature page** Create `docs/docs/sidebar/features/agent-hardening.md` with: + - Overview of least-privilege mode - Configuration reference - Sudoers drop-in (copy from spec) @@ -937,16 +968,16 @@ just go::vet - [ ] **Step 3: Verify no stray RunCmd calls for write operations** -Check each write command from the spec against the codebase to -confirm it uses `RunPrivilegedCmd`: +Check each write command from the spec against the codebase to confirm it uses +`RunPrivilegedCmd`: ```bash grep -rn 'RunCmd.*"systemctl".*"start\|stop\|restart\|enable\|disable\|daemon-reload"' \ internal/provider/ --include="*.go" | grep -v _test.go | grep -v RunPrivilegedCmd ``` -Repeat for other write commands (`useradd`, `usermod`, `apt-get`, -`shutdown`, `sysctl -p`, etc.). Expect: no matches. +Repeat for other write commands (`useradd`, `usermod`, `apt-get`, `shutdown`, +`sysctl -p`, etc.). Expect: no matches. - [ ] **Step 4: Verify read commands stayed on RunCmd** diff --git a/internal/agent/preflight.go b/internal/agent/preflight.go index a04dd8848..78aef988a 100644 --- a/internal/agent/preflight.go +++ b/internal/agent/preflight.go @@ -71,36 +71,32 @@ var requiredCapabilities = map[string]int{ // Overridable in tests via export_test.go. var procStatusPath = "/proc/self/status" -// RunPreflight runs sudo and capability checks based on the provided flags. -// Returns the combined results and whether all checks passed. +// RunPreflight runs sudo and capability checks. When called, it always +// checks both sudo access and Linux capabilities. Returns the combined +// results and whether all checks passed. func RunPreflight( logger *slog.Logger, execManager exec.Manager, - checkSudo bool, - checkCaps bool, ) ([]PreflightResult, bool) { - var results []PreflightResult allPassed := true - if checkSudo { - sudoResults := checkSudoAccess(logger, execManager) - results = append(results, sudoResults...) + sudoResults := checkSudoAccess(logger, execManager) + capResults := checkCapabilities(logger) - for _, r := range sudoResults { - if !r.Passed { - allPassed = false - } + results := make([]PreflightResult, 0, len(sudoResults)+len(capResults)) + results = append(results, sudoResults...) + + for _, r := range sudoResults { + if !r.Passed { + allPassed = false } } - if checkCaps { - capResults := checkCapabilities(logger) - results = append(results, capResults...) + results = append(results, capResults...) - for _, r := range capResults { - if !r.Passed { - allPassed = false - } + for _, r := range capResults { + if !r.Passed { + allPassed = false } } diff --git a/internal/agent/preflight_public_test.go b/internal/agent/preflight_public_test.go index 9e0a502ca..8dbc52779 100644 --- a/internal/agent/preflight_public_test.go +++ b/internal/agent/preflight_public_test.go @@ -190,15 +190,11 @@ func (s *PreflightPublicTestSuite) TestCheckCapabilities() { func (s *PreflightPublicTestSuite) TestRunPreflight() { tests := []struct { name string - checkSudo bool - checkCaps bool setup func() validateFunc func([]agent.PreflightResult, bool) }{ { - name: "when both checks pass", - checkSudo: true, - checkCaps: true, + name: "when both checks pass", setup: func() { s.mockExecMgr.EXPECT(). RunCmd("sudo", gomock.Any()). @@ -217,14 +213,18 @@ func (s *PreflightPublicTestSuite) TestRunPreflight() { }, }, { - name: "when sudo check fails", - checkSudo: true, - checkCaps: false, + name: "when sudo check fails", setup: func() { s.mockExecMgr.EXPECT(). RunCmd("sudo", gomock.Any()). Return("", fmt.Errorf("sudo failed")). AnyTimes() + + path := filepath.Join(s.tmpDir, "status_sudo_fail") + content := "Name:\tosapi\nCapEff:\t000000000000003f\n" + err := os.WriteFile(path, []byte(content), 0o644) + s.Require().NoError(err) + agent.SetProcStatusPath(path) }, validateFunc: func(results []agent.PreflightResult, allPassed bool) { s.False(allPassed) @@ -232,10 +232,13 @@ func (s *PreflightPublicTestSuite) TestRunPreflight() { }, }, { - name: "when caps check fails", - checkSudo: false, - checkCaps: true, + name: "when caps check fails", setup: func() { + s.mockExecMgr.EXPECT(). + RunCmd("sudo", gomock.Any()). + Return("/usr/bin/something", nil). + AnyTimes() + path := filepath.Join(s.tmpDir, "status_fail") content := "Name:\tosapi\nCapEff:\t0000000000000000\n" err := os.WriteFile(path, []byte(content), 0o644) @@ -255,8 +258,6 @@ func (s *PreflightPublicTestSuite) TestRunPreflight() { results, allPassed := agent.RunPreflight( s.logger, s.mockExecMgr, - tc.checkSudo, - tc.checkCaps, ) tc.validateFunc(results, allPassed) }) diff --git a/internal/agent/server.go b/internal/agent/server.go index bc09250b6..6eb3a4679 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -49,8 +49,8 @@ func (a *Agent) Start() { // Run preflight checks when privilege escalation is enabled. pe := a.appConfig.Agent.PrivilegeEscalation - if pe.Sudo || pe.Capabilities { - results, ok := RunPreflight(a.logger, a.execManager, pe.Sudo, pe.Capabilities) + if pe.Enabled { + results, ok := RunPreflight(a.logger, a.execManager) if !ok { for _, r := range results { if !r.Passed { diff --git a/internal/config/types.go b/internal/config/types.go index d29cd18e8..3b9605010 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -351,11 +351,12 @@ type ProcessConditions struct { } // PrivilegeEscalation configuration for least-privilege agent mode. +// When enabled, write commands use sudo and Linux capabilities are +// verified at startup. type PrivilegeEscalation struct { - // Sudo prepends "sudo" to write commands when true. - Sudo bool `mapstructure:"sudo"` - // Capabilities verifies Linux capabilities at startup when true. - Capabilities bool `mapstructure:"capabilities"` + // Enabled activates least-privilege mode: sudo for write commands + // and capability verification at startup. + Enabled bool `mapstructure:"enabled"` } // AgentConfig configuration settings. diff --git a/test/integration/osapi.yaml b/test/integration/osapi.yaml index b6add11a9..b7c5f778a 100644 --- a/test/integration/osapi.yaml +++ b/test/integration/osapi.yaml @@ -119,6 +119,5 @@ agent: metrics: enabled: false privilege_escalation: - sudo: false - capabilities: false + enabled: false From 491cabb729b3732e885669b98778466eb97fdf6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 15:15:49 -0700 Subject: [PATCH 17/21] refactor(exec): add CommandExecutor interface for testable sudo Extract command execution into a CommandExecutor interface so RunPrivilegedCmd tests can assert exact command/args via gomock without executing real commands or triggering sudo prompts. Co-Authored-By: Claude --- internal/exec/exec.go | 45 ++++--- internal/exec/export_test.go | 27 ++++ internal/exec/mocks/command_executor.gen.go | 49 ++++++++ internal/exec/mocks/generate.go | 1 + .../exec/run_privileged_cmd_public_test.go | 118 +++++++++++------- internal/exec/types.go | 16 ++- 6 files changed, 193 insertions(+), 63 deletions(-) create mode 100644 internal/exec/export_test.go create mode 100644 internal/exec/mocks/command_executor.gen.go diff --git a/internal/exec/exec.go b/internal/exec/exec.go index f04e31439..21ccc21f7 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -30,21 +30,14 @@ import ( const maxLogOutputLen = 200 -// New factory to create a new Exec instance. -func New( - logger *slog.Logger, - sudo bool, -) *Exec { - return &Exec{ - logger: logger.With(slog.String("subsystem", "exec")), - sudo: sudo, - } +// defaultExecutor implements CommandExecutor by running real OS commands +// via os/exec. +type defaultExecutor struct { + logger *slog.Logger } -// RunCmdImpl executes the provided command with the specified arguments and -// an optional working directory. It captures and logs the combined output -// (stdout and stderr) of the command. -func (e *Exec) RunCmdImpl( +// Execute runs the command and returns its combined output. +func (d *defaultExecutor) Execute( name string, args []string, cwd string, @@ -60,7 +53,7 @@ func (e *Exec) RunCmdImpl( logOutput = logOutput[:maxLogOutputLen] + fmt.Sprintf("... (%d bytes total)", len(out)) } - e.logger.Debug( + d.logger.Debug( "exec", slog.String("command", strings.Join(cmd.Args, " ")), slog.String("cwd", cwd), @@ -73,3 +66,27 @@ func (e *Exec) RunCmdImpl( return string(out), nil } + +// New factory to create a new Exec instance. +func New( + logger *slog.Logger, + sudo bool, +) *Exec { + l := logger.With(slog.String("subsystem", "exec")) + + return &Exec{ + logger: l, + sudo: sudo, + executor: &defaultExecutor{logger: l}, + } +} + +// RunCmdImpl executes the provided command with the specified arguments and +// an optional working directory. It delegates to the CommandExecutor. +func (e *Exec) RunCmdImpl( + name string, + args []string, + cwd string, +) (string, error) { + return e.executor.Execute(name, args, cwd) +} diff --git a/internal/exec/export_test.go b/internal/exec/export_test.go new file mode 100644 index 000000000..44104966c --- /dev/null +++ b/internal/exec/export_test.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package exec + +// SetExecutor replaces the CommandExecutor on an Exec instance. +// Used by tests to inject a mock executor. +func SetExecutor(e *Exec, executor CommandExecutor) { + e.executor = executor +} diff --git a/internal/exec/mocks/command_executor.gen.go b/internal/exec/mocks/command_executor.gen.go new file mode 100644 index 000000000..8601d4045 --- /dev/null +++ b/internal/exec/mocks/command_executor.gen.go @@ -0,0 +1,49 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ../types.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockCommandExecutor is a mock of CommandExecutor interface. +type MockCommandExecutor struct { + ctrl *gomock.Controller + recorder *MockCommandExecutorMockRecorder +} + +// MockCommandExecutorMockRecorder is the mock recorder for MockCommandExecutor. +type MockCommandExecutorMockRecorder struct { + mock *MockCommandExecutor +} + +// NewMockCommandExecutor creates a new mock instance. +func NewMockCommandExecutor(ctrl *gomock.Controller) *MockCommandExecutor { + mock := &MockCommandExecutor{ctrl: ctrl} + mock.recorder = &MockCommandExecutorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCommandExecutor) EXPECT() *MockCommandExecutorMockRecorder { + return m.recorder +} + +// Execute mocks base method. +func (m *MockCommandExecutor) Execute(name string, args []string, cwd string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Execute", name, args, cwd) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Execute indicates an expected call of Execute. +func (mr *MockCommandExecutorMockRecorder) Execute(name, args, cwd interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommandExecutor)(nil).Execute), name, args, cwd) +} diff --git a/internal/exec/mocks/generate.go b/internal/exec/mocks/generate.go index 59f7d3063..a94cfef4a 100644 --- a/internal/exec/mocks/generate.go +++ b/internal/exec/mocks/generate.go @@ -21,3 +21,4 @@ package mocks //go:generate go tool github.com/golang/mock/mockgen -source=../manager.go -destination=manager.gen.go -package=mocks +//go:generate go tool github.com/golang/mock/mockgen -source=../types.go -destination=command_executor.gen.go -package=mocks diff --git a/internal/exec/run_privileged_cmd_public_test.go b/internal/exec/run_privileged_cmd_public_test.go index 94c180428..563f28fb8 100644 --- a/internal/exec/run_privileged_cmd_public_test.go +++ b/internal/exec/run_privileged_cmd_public_test.go @@ -21,96 +21,120 @@ package exec_test import ( + "fmt" "log/slog" "os" "testing" + "github.com/golang/mock/gomock" "github.com/stretchr/testify/suite" "github.com/retr0h/osapi/internal/exec" + "github.com/retr0h/osapi/internal/exec/mocks" ) type RunPrivilegedCmdPublicTestSuite struct { suite.Suite - logger *slog.Logger + ctrl *gomock.Controller + mockExecutor *mocks.MockCommandExecutor + logger *slog.Logger } -func (suite *RunPrivilegedCmdPublicTestSuite) SetupTest() { - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) +func (s *RunPrivilegedCmdPublicTestSuite) SetupTest() { + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } -func (suite *RunPrivilegedCmdPublicTestSuite) TearDownTest() {} +func (s *RunPrivilegedCmdPublicTestSuite) SetupSubTest() { + s.ctrl = gomock.NewController(s.T()) + s.mockExecutor = mocks.NewMockCommandExecutor(s.ctrl) +} + +func (s *RunPrivilegedCmdPublicTestSuite) TearDownSubTest() { + s.ctrl.Finish() +} -func (suite *RunPrivilegedCmdPublicTestSuite) TestRunPrivilegedCmd() { +func (s *RunPrivilegedCmdPublicTestSuite) TestRunPrivilegedCmd() { tests := []struct { name string sudo bool command string args []string - expectError bool + setupMock func() validateFunc func(string, error) }{ { - name: "without sudo runs command directly", - sudo: false, - command: "echo", - args: []string{"-n", "hello"}, - expectError: false, - validateFunc: func(output string, _ error) { - suite.Require().Equal("hello", output) + name: "without sudo runs command directly", + sudo: false, + command: "echo", + args: []string{"-n", "hello"}, + setupMock: func() { + s.mockExecutor.EXPECT(). + Execute("echo", []string{"-n", "hello"}, ""). + Return("hello", nil) + }, + validateFunc: func(output string, err error) { + s.NoError(err) + s.Equal("hello", output) }, }, { - name: "without sudo invalid command returns error", - sudo: false, - command: "nonexistent-command-xyz", - args: []string{}, - expectError: true, - validateFunc: func(_ string, err error) { - suite.Require().Contains(err.Error(), "not found") + name: "with sudo prepends sudo to args", + sudo: true, + command: "echo", + args: []string{"-n", "hello"}, + setupMock: func() { + s.mockExecutor.EXPECT(). + Execute("sudo", []string{"echo", "-n", "hello"}, ""). + Return("hello", nil) + }, + validateFunc: func(output string, err error) { + s.NoError(err) + s.Equal("hello", output) }, }, { - name: "with sudo prepends sudo to command", - sudo: true, - command: "nonexistent-command-xyz", - args: []string{"arg1"}, - expectError: true, - validateFunc: func(_ string, err error) { - // When sudo=true, the exec manager prepends "sudo" to the - // command. The error varies by environment (sudo may not be - // installed, or may prompt for a password), but the attempt - // to run sudo confirms the prepend behavior. - suite.Require().Error(err) + name: "with sudo no args", + sudo: true, + command: "systemctl", + args: nil, + setupMock: func() { + s.mockExecutor.EXPECT(). + Execute("sudo", []string{"systemctl"}, ""). + Return("", nil) + }, + validateFunc: func(output string, err error) { + s.NoError(err) + s.Equal("", output) }, }, { - name: "with sudo and no args", - sudo: true, - command: "nonexistent-command-xyz", - args: []string{}, - expectError: true, + name: "executor error propagates", + sudo: false, + command: "nonexistent", + args: []string{}, + setupMock: func() { + s.mockExecutor.EXPECT(). + Execute("nonexistent", []string{}, ""). + Return("", fmt.Errorf("command not found")) + }, validateFunc: func(_ string, err error) { - suite.Require().Error(err) + s.Error(err) + s.Contains(err.Error(), "command not found") }, }, } for _, tc := range tests { - suite.Run(tc.name, func() { - em := exec.New(suite.logger, tc.sudo) + s.Run(tc.name, func() { + tc.setupMock() + + em := exec.New(s.logger, tc.sudo) + exec.SetExecutor(em, s.mockExecutor) output, err := em.RunPrivilegedCmd(tc.command, tc.args) - if tc.expectError { - suite.Require().Error(err) - } else { - suite.Require().NoError(err) - } - if tc.validateFunc != nil { - tc.validateFunc(output, err) - } + tc.validateFunc(output, err) }) } } diff --git a/internal/exec/types.go b/internal/exec/types.go index 347c73320..60ad955be 100644 --- a/internal/exec/types.go +++ b/internal/exec/types.go @@ -24,10 +24,22 @@ import ( "log/slog" ) +// CommandExecutor executes OS commands. The default implementation +// runs real commands via os/exec. Tests inject a mock to assert +// commands without executing them. +type CommandExecutor interface { + Execute( + name string, + args []string, + cwd string, + ) (string, error) +} + // Exec disk implementation. type Exec struct { - logger *slog.Logger - sudo bool + logger *slog.Logger + sudo bool + executor CommandExecutor } // CmdResult contains the full result of a command execution From ead68bb2603e279df87d008f805dbc0ef3bdd86f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 15:16:37 -0700 Subject: [PATCH 18/21] refactor(exec): add CommandExecutor interface for testable sudo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract command execution into a CommandExecutor interface so RunPrivilegedCmd tests can assert exact command/args via gomock without executing real commands or prompting for sudo passwords. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/docs/sidebar/features/agent-hardening.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/docs/sidebar/features/agent-hardening.md b/docs/docs/sidebar/features/agent-hardening.md index 4df4f55ae..23fba6c20 100644 --- a/docs/docs/sidebar/features/agent-hardening.md +++ b/docs/docs/sidebar/features/agent-hardening.md @@ -116,9 +116,8 @@ sudo setcap \ /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: +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 | | --------------------- | --- | ------------------------------- | From f0b61101fc9695ae5c2b56373952debba8f1a3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 15:37:13 -0700 Subject: [PATCH 19/21] test(agent): add coverage for readCapEff and preflight Start paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test cases for invalid hex CapEff, missing CapEff line, and preflight failure/success in agent Start(). Add defense-in-depth comment to user update validation.Struct call. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/agent/agent_public_test.go | 101 ++++++++++++++++++ internal/agent/fixture_public_test.go | 4 +- internal/agent/preflight_public_test.go | 34 ++++++ .../controller/api/node/user/user_update.go | 2 + 4 files changed, 140 insertions(+), 1 deletion(-) diff --git a/internal/agent/agent_public_test.go b/internal/agent/agent_public_test.go index 9ab2649a6..f925b5294 100644 --- a/internal/agent/agent_public_test.go +++ b/internal/agent/agent_public_test.go @@ -22,8 +22,11 @@ package agent_test import ( "context" + "fmt" "log/slog" "net" + "os" + "path/filepath" "testing" "time" @@ -34,6 +37,7 @@ import ( "github.com/retr0h/osapi/internal/agent" "github.com/retr0h/osapi/internal/config" + execmocks "github.com/retr0h/osapi/internal/exec/mocks" "github.com/retr0h/osapi/internal/job" "github.com/retr0h/osapi/internal/job/mocks" commandMocks "github.com/retr0h/osapi/internal/provider/command/mocks" @@ -164,6 +168,103 @@ func (s *AgentPublicTestSuite) TestStart() { a.Stop(stopCtx) }, }, + { + name: "returns early when preflight fails", + setupFunc: func() *agent.Agent { + mockExecMgr := execmocks.NewMockManager(s.mockCtrl) + mockExecMgr.EXPECT(). + RunCmd("sudo", gomock.Any()). + Return("", fmt.Errorf("sudo: a password is required")). + AnyTimes() + + cfg := s.appConfig + cfg.Agent.PrivilegeEscalation = config.PrivilegeEscalation{ + Enabled: true, + } + + return newTestAgent(newTestAgentParams{ + appFs: s.appFs, + appConfig: cfg, + logger: s.logger, + jobClient: s.mockJobClient, + streamName: "test-stream", + hostProvider: hostMocks.NewDefaultMockProvider(s.mockCtrl), + diskProvider: diskMocks.NewDefaultMockProvider(s.mockCtrl), + memProvider: memMocks.NewDefaultMockProvider(s.mockCtrl), + loadProvider: loadMocks.NewDefaultMockProvider(s.mockCtrl), + dnsProvider: dnsMocks.NewDefaultMockProvider(s.mockCtrl), + pingProvider: pingMocks.NewDefaultMockProvider(s.mockCtrl), + netinfoProvider: netinfoMocks.NewDefaultMockProvider(s.mockCtrl), + commandProvider: commandMocks.NewDefaultMockProvider(s.mockCtrl), + processProvider: processMocks.NewDefaultMockProvider(s.mockCtrl), + execManager: mockExecMgr, + }) + }, + stopFunc: func(a *agent.Agent) { + // Agent should not have started consumers, so IsReady fails. + err := a.IsReady() + s.Error(err) + }, + }, + { + name: "starts when preflight passes", + setupFunc: func() *agent.Agent { + mockExecMgr := execmocks.NewMockManager(s.mockCtrl) + mockExecMgr.EXPECT(). + RunCmd("sudo", gomock.Any()). + Return("/usr/bin/something", nil). + AnyTimes() + + // Write a fake proc status file with all capabilities set. + tmpDir := s.T().TempDir() + path := filepath.Join(tmpDir, "status") + err := os.WriteFile(path, []byte("Name:\tosapi\nCapEff:\t000000000000003f\n"), 0o644) + s.Require().NoError(err) + agent.SetProcStatusPath(path) + s.T().Cleanup(func() { + agent.ResetProcStatusPath() + }) + + cfg := s.appConfig + cfg.Agent.PrivilegeEscalation = config.PrivilegeEscalation{ + Enabled: true, + } + + s.mockJobClient.EXPECT(). + CreateOrUpdateConsumer(gomock.Any(), "test-stream", gomock.Any()). + Return(nil). + Times(6) + + s.mockJobClient.EXPECT(). + ConsumeJobs(gomock.Any(), "test-stream", gomock.Any(), gomock.Any(), gomock.Any()). + Return(context.Canceled). + Times(6) + + return newTestAgent(newTestAgentParams{ + appFs: s.appFs, + appConfig: cfg, + logger: s.logger, + jobClient: s.mockJobClient, + streamName: "test-stream", + hostProvider: hostMocks.NewDefaultMockProvider(s.mockCtrl), + diskProvider: diskMocks.NewDefaultMockProvider(s.mockCtrl), + memProvider: memMocks.NewDefaultMockProvider(s.mockCtrl), + loadProvider: loadMocks.NewDefaultMockProvider(s.mockCtrl), + dnsProvider: dnsMocks.NewDefaultMockProvider(s.mockCtrl), + pingProvider: pingMocks.NewDefaultMockProvider(s.mockCtrl), + netinfoProvider: netinfoMocks.NewDefaultMockProvider(s.mockCtrl), + commandProvider: commandMocks.NewDefaultMockProvider(s.mockCtrl), + processProvider: processMocks.NewDefaultMockProvider(s.mockCtrl), + execManager: mockExecMgr, + }) + }, + stopFunc: func(a *agent.Agent) { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + a.Stop(stopCtx) + }, + }, { name: "stop times out when agents are slow to finish", setupFunc: func() *agent.Agent { diff --git a/internal/agent/fixture_public_test.go b/internal/agent/fixture_public_test.go index 53ba7af5c..f48932512 100644 --- a/internal/agent/fixture_public_test.go +++ b/internal/agent/fixture_public_test.go @@ -28,6 +28,7 @@ import ( "github.com/retr0h/osapi/internal/agent" "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/exec" "github.com/retr0h/osapi/internal/job/client" "github.com/retr0h/osapi/internal/provider/command" dockerProv "github.com/retr0h/osapi/internal/provider/container/docker" @@ -71,6 +72,7 @@ type newTestAgentParams struct { timezoneProvider timezoneProv.Provider powerProvider powerProv.Provider processProvider process.Provider + execManager exec.Manager registryKV jetstream.KeyValue factsKV jetstream.KeyValue } @@ -152,6 +154,6 @@ func newTestAgent(p newTestAgentParams) *agent.Agent { registry, p.registryKV, p.factsKV, - nil, + p.execManager, ) } diff --git a/internal/agent/preflight_public_test.go b/internal/agent/preflight_public_test.go index 8dbc52779..6f184bb69 100644 --- a/internal/agent/preflight_public_test.go +++ b/internal/agent/preflight_public_test.go @@ -176,6 +176,40 @@ func (s *PreflightPublicTestSuite) TestCheckCapabilities() { } }, }, + { + name: "when CapEff line has invalid hex", + setup: func() { + path := filepath.Join(s.tmpDir, "status_bad_hex") + content := "Name:\tosapi\nCapEff:\tNOTHEX\n" + err := os.WriteFile(path, []byte(content), 0o644) + s.Require().NoError(err) + agent.SetProcStatusPath(path) + }, + validateFunc: func(results []agent.PreflightResult) { + s.NotEmpty(results) + for _, r := range results { + s.False(r.Passed, "expected %s to fail", r.Name) + s.Contains(r.Error, "failed to read capabilities") + } + }, + }, + { + name: "when CapEff line not present", + setup: func() { + path := filepath.Join(s.tmpDir, "status_no_capeff") + content := "Name:\tosapi\nCapInh:\t0000000000000000\n" + err := os.WriteFile(path, []byte(content), 0o644) + s.Require().NoError(err) + agent.SetProcStatusPath(path) + }, + validateFunc: func(results []agent.PreflightResult) { + s.NotEmpty(results) + for _, r := range results { + s.False(r.Passed, "expected %s to fail", r.Name) + s.Contains(r.Error, "failed to read capabilities") + } + }, + }, } for _, tc := range tests { diff --git a/internal/controller/api/node/user/user_update.go b/internal/controller/api/node/user/user_update.go index 817213870..e4196656a 100644 --- a/internal/controller/api/node/user/user_update.go +++ b/internal/controller/api/node/user/user_update.go @@ -43,6 +43,8 @@ func (u *User) PutNodeUser( return gen.PutNodeUser400JSONResponse{Error: &errMsg}, nil } + // Defense in depth: current fields use omitempty so validation + // always passes, but guards against future field additions. if errMsg, ok := validation.Struct(request.Body); !ok { return gen.PutNodeUser400JSONResponse{Error: &errMsg}, nil } From 62ac413b9229d5e589caac2db87b2def6c11578a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 15:47:26 -0700 Subject: [PATCH 20/21] test(agent): cover scanner.Err path in readCapEff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write a line exceeding bufio.MaxScanTokenSize to trigger a scanner error, covering the remaining branch in readCapEff. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/agent/preflight_public_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/internal/agent/preflight_public_test.go b/internal/agent/preflight_public_test.go index 6f184bb69..8ad7cbe38 100644 --- a/internal/agent/preflight_public_test.go +++ b/internal/agent/preflight_public_test.go @@ -193,6 +193,30 @@ func (s *PreflightPublicTestSuite) TestCheckCapabilities() { } }, }, + { + name: "when scanner encounters read error", + setup: func() { + // Write a line longer than bufio.MaxScanTokenSize + // (64 KiB) to trigger a scanner error before + // reaching the CapEff line. + path := filepath.Join(s.tmpDir, "status_long_line") + longLine := make([]byte, 70000) + for i := range longLine { + longLine[i] = 'x' + } + content := string(longLine) + "\nCapEff:\t000000000000003f\n" + err := os.WriteFile(path, []byte(content), 0o644) + s.Require().NoError(err) + agent.SetProcStatusPath(path) + }, + validateFunc: func(results []agent.PreflightResult) { + s.NotEmpty(results) + for _, r := range results { + s.False(r.Passed, "expected %s to fail", r.Name) + s.Contains(r.Error, "failed to read capabilities") + } + }, + }, { name: "when CapEff line not present", setup: func() { From 680706a77314c3dda69fb116155eba22aa605038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 2 Apr 2026 15:55:29 -0700 Subject: [PATCH 21/21] style(agent): fix multi-line function call formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/agent/agent_public_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/agent/agent_public_test.go b/internal/agent/agent_public_test.go index f925b5294..9b9f606c3 100644 --- a/internal/agent/agent_public_test.go +++ b/internal/agent/agent_public_test.go @@ -218,7 +218,11 @@ func (s *AgentPublicTestSuite) TestStart() { // Write a fake proc status file with all capabilities set. tmpDir := s.T().TempDir() path := filepath.Join(tmpDir, "status") - err := os.WriteFile(path, []byte("Name:\tosapi\nCapEff:\t000000000000003f\n"), 0o644) + err := os.WriteFile( + path, + []byte("Name:\tosapi\nCapEff:\t000000000000003f\n"), + 0o644, + ) s.Require().NoError(err) agent.SetProcStatusPath(path) s.T().Cleanup(func() {