Compare commits

..

1 Commits

Author SHA1 Message Date
9459839d74 fix: restrict docker commands for ai-worker user
Remove ai-worker from docker group and enforce sudo whitelist.

SECURITY: Being in the docker group gives unrestricted access to the
Docker daemon socket (/var/run/docker.sock), allowing any docker command:
docker exec, docker cp, docker run -v /:/host, docker commit, etc.

Changes:
- Remove extraGroups = ["docker"] from ai-worker user definition
- Add comprehensive sudo NOPASSWD whitelist for safe docker subcommands
  ALLOWED: ps, inspect, logs, images, info, version, stats, start, stop,
  restart, rm, rmi, wait, pull, build, run, compose, system,
  network ls, volume ls
  BLOCKED (implicitly): exec, cp, commit, diff, export, import, load,
  save, attach, push, tag, create, plugin, network create, volume create
- Update ai-worker-restricted.nix module to reflect new approach
- Update README-ai-worker.md with new security model and examples

All docker commands must now be prefixed with sudo.
The Hermes agent's host_run tool needs to be updated to prepend sudo.
2026-05-20 20:34:19 -04:00
3 changed files with 220 additions and 197 deletions

View File

@@ -1,74 +1,80 @@
# AI Worker Restricted Access
This module provides SSH access for the AI worker (hermes-agent) to run docker commands on the host with restrictions.
This module provides SSH access for the AI worker (hermes-agent) to run docker commands on the host.
## Security Model
### Overview
The `ai-worker` user is a member of the `docker` group, but the `docker` binary is wrapped with a script that **blocks dangerous subcommands** while allowing safe operations.
The `ai-worker` user has **no direct docker group access**. All docker commands must go through `sudo`, and only specific subcommands are whitelisted:
### Blocked Commands
- **Container lifecycle**: `docker ps`, `docker inspect`, `docker logs`, `docker images`, `docker info`, `docker version`, `docker stats`
- **Control**: `docker start`, `docker stop`, `docker restart`, `docker rm`, `docker rmi`, `docker wait`
- **Image management**: `docker pull`, `docker build`, `docker run`, `docker compose`
- **Disk cleanup**: `docker system`
- **Network/Volume**: `docker network ls`, `docker volume ls` (read-only)
These commands are intercepted by the docker wrapper and rejected:
### EXPLICITLY BLOCKED (not in sudo whitelist)
| Command | Risk | Reason |
| Command | Risk | Result |
|---------|------|--------|
| `docker exec` | Execute arbitrary commands inside running containers | FILE MODIFICATION |
| `docker cp` | Copy files between containers and host | FILE ACCESS |
| `docker commit` | Create images from running containers | DATA EXFIL |
| `docker diff` | Inspect filesystem changes | INFO LEAK |
| `docker export` | Export container filesystem as tar archive | DATA EXFIL |
| `docker import` | Import a tar archive to create filesystem | FILE INJECTION |
| `docker load` | Load images from tar archive | FILE INJECTION |
| `docker save` | Save images to tar archive | DATA EXFIL |
| `docker attach` | Attach to running container's stdio | INTERACTIVE ACCESS |
| `docker push` | Push images to remote registries | DATA EXFIL |
| `docker tag` | Tag/rename images | DATA EXFIL |
| `docker exec` | Execute arbitrary commands inside containers (FILE MODIFICATION) | Blocked by sudo |
| `docker cp` | Copy files between containers and host | Blocked by sudo |
| `docker commit` | Create images from running containers (data exfil) | Blocked by sudo |
| `docker diff` | Inspect filesystem changes | Blocked by sudo |
| `docker export` | Export container filesystem | Blocked by sudo |
| `docker import` | Import filesystem archives | Blocked by sudo |
| `docker load` | Load docker images | Blocked by sudo |
| `docker save` | Save docker images to tar | Blocked by sudo |
| `docker attach` | Interactive access to containers | Blocked by sudo |
| `docker push` | Push images to registries | Blocked by sudo |
| `docker tag` | Rename images | Blocked by sudo |
Also blocked in compose context: `docker compose exec`, `docker compose cp`, etc.
### Why This Approach?
### Allowed Commands
Previously, `ai-worker` was a member of the `docker` group, which gives **unrestricted** access to the Docker daemon socket (`/var/run/docker.sock`). Users in the `docker` group can run ANY docker command, including:
These commands work normally:
- `docker exec -it container bash` — full shell access to any container
- `docker cp /host/file container:/path` — file modification inside containers
- `docker run -v /:/host alpine` — full host filesystem access
- `docker ps` — list containers
- `docker images` — list images
- `docker inspect` — inspect containers/images
- `docker logs` — view container logs
- `docker start` — start a stopped container
- `docker stop` — stop a running container
- `docker restart` — restart a container
- `docker rm` — remove a stopped container
- `docker rmi` — remove an image
- `docker pull` — pull an image
- `docker build` — build an image
- `docker run` — create and start a container
- `docker compose` — compose orchestration (but not `compose exec`)
- `docker system` — disk management
- `docker network ls` — list networks
- `docker volume ls` — list volumes
### How It Works
1. A wrapper script intercepts `docker` calls in the user's PATH
2. It parses the first non-flag argument to determine the subcommand
3. If the subcommand is in the blocklist, it prints an error and exits
4. Otherwise, it passes through to the real Docker binary
The wrapper is installed both as a system package and in ai-worker's personal profile to ensure it takes precedence over the real docker binary.
### Why Not Use Docker Authorization Plugins?
Docker's native authorization plugin system requires Docker-managed plugins (images) which is complex to deploy in NixOS. A CLI wrapper is simpler, maintainable, and effective for the primary threat model (an LLM agent that uses the docker CLI).
Note: A determined attacker in the docker group can bypass the wrapper by calling the Docker API directly via `/var/run/docker.sock`. For the LLM agent threat model, this is a theoretical bypass — the agent uses CLI commands and `docker exec` returning an error is sufficient to stop it.
By removing the `docker` group and using a sudo whitelist instead, we enforce the principle of least privilege.
### Filesystem Access
- **Home directory**: `/home/ai-worker` (standard user home)
- **No bind mounts**: Cannot access `/home/gortium/infra` or other host files
- **Cannot access**: Any files outside standard system paths
### Sudo Access
- **Restricted**: ai-worker has `NOPASSWD` access only to whitelisted commands
- Cannot run `nh`, `nixos-rebuild`, `nixpkgs-fmt`, or `nix` with elevated permissions
## Workflow: SSH + Restricted Docker
All docker commands must be prefixed with `sudo`:
```bash
# From Hermes container, SSH to host
ssh -i /path/to/ssh/key ai-worker@host.docker.internal
# Check container status (works)
sudo docker ps
# Restart a container (works)
sudo docker restart ollama
# Run benchmark (works - docker run is allowed)
sudo docker run --rm alpine echo "test"
# ANY of these will FAIL (not in whitelist):
sudo docker exec ollama ollama list # FAILS - docker exec blocked
sudo docker cp file.txt container:/path/ # FAILS - docker cp blocked
sudo docker commit container new-image # FAILS - docker commit blocked
# For ollama operations, use the HTTP API instead of docker exec:
curl http://ollama:11434/api/tags
```
## SSH Access
Connect as:
@@ -80,42 +86,37 @@ The working directory will be `/home/ai-worker`. No infra repo access.
## Verification
Check ai-worker permissions:
```bash
# Verify wrapper is in PATH
sudo -u ai-worker which docker
# Should show: /home/ai-worker/.nix-profile/bin/docker (wrapped version)
# On the host, as root or gortium:
sudo -u ai-worker sudo -l
# Should show the whitelisted commands only (no docker exec/cp/commit)
# Test blocked command (should fail)
sudo -u ai-worker docker exec ollama ollama list
# Expected: ERROR: docker 'exec' is blocked by security policy
# Test allowed command (should work)
sudo -u ai-worker docker ps
# Expected: CONTAINER ID IMAGE ...
# Verify docker group membership
# Verify NOT in docker group
groups ai-worker
# Should show: ai-worker docker
# Should show: ai-worker (NO docker group)
```
## Troubleshooting
If docker commands fail unexpectedly:
If docker commands fail:
```bash
# Check which docker binary is being used
which docker
# If this shows /run/current-system/sw/bin/docker, the wrapper is not in PATH
# Check sudo permissions
sudo -u ai-worker sudo -l | grep docker
# Check if the wrapper is installed
ls -la $(which docker)
# Verify group membership
groups ai-worker
# Verify you're running as the right user
whoami
# Test allowed command
sudo -u ai-worker sudo docker ps
# Test blocked command (should fail)
sudo -u ai-worker sudo docker exec ollama ollama list
# Expected: "Sorry, user ai-worker is not allowed to execute"
```
If SSH connection fails:
```bash
# Check SSH key is authorized
cat /home/ai-worker/.ssh/authorized_keys

View File

@@ -2,123 +2,23 @@
with lib;
let
# Docker subcommands that are BLOCKED for ai-worker
# These commands allow file modification inside containers or data exfiltration.
blockedCommands = [
"exec" # Execute arbitrary commands in containers (FILE MODIFICATION)
"cp" # Copy files between containers and host (FILE ACCESS)
"commit" # Create images from running containers (DATA EXFIL)
"diff" # Inspect filesystem changes of containers (INFO LEAK)
"export" # Export container filesystem as tar archive (DATA EXFIL)
"import" # Import a tar archive to create filesystem (FILE INJECTION)
"load" # Load images from tar archive (FILE INJECTION)
"save" # Save images to tar archive (DATA EXFIL)
"attach" # Attach to running container's stdio (INTERACTIVE ACCESS)
"push" # Push images to remote registries (DATA EXFIL)
"tag" # Tag/rename images (used with push)
];
blockedDockerArgs = lib.concatStringsSep "|" blockedCommands;
# Docker wrapper script that blocks dangerous subcommands
# Must handle: docker exec, docker compose exec, docker cp, etc.
restrictedDockerScript = pkgs.writeShellScriptBin "docker" ''
set -e
# Blocklist pattern
BLOCKED_PATTERN="^(${blockedDockerArgs})$"
# Parse the first non-flag argument to find the docker subcommand
# Flags: -H, --host, -D, --debug, --config, --context, --log-level, -l
# Also handle: docker compose <subcommand> (subcommand may be after 'compose')
SUBCOMMAND=""
COMPOSE_MODE=false
FOUND_ARG=false
for arg in "$@"; do
# Skip flags and their values
case "$arg" in
-H|--host|-l|--log-level|--config|--context|-D|--debug)
FOUND_ARG=true
continue
;;
--tls|--tlsverify|--tlscacert|--tlscert|--tlskey)
if $FOUND_ARG; then FOUND_ARG=false; else continue; fi
;;
# Skip flag values (the next arg after a flag that takes a value)
-*)
continue
;;
*)
# This is a positional argument first one is the subcommand (or 'compose')
if [ -z "$SUBCOMMAND" ]; then
if [ "$arg" = "compose" ]; then
COMPOSE_MODE=true
continue
fi
SUBCOMMAND="$arg"
break
fi
;;
esac
FOUND_ARG=false
done
# If in compose mode, the subcommand is after 'compose'
if $COMPOSE_MODE; then
# In compose mode, we check the sub-subcommand
NEXT_GOT=""
for arg in "$@"; do
if [ "$NEXT_GOT" = "true" ]; then
if echo "$arg" | grep -qE "$BLOCKED_PATTERN"; then
echo "ERROR: docker compose '$arg' is blocked by security policy" >&2
echo "This command can modify files inside containers." >&2
exit 1
fi
break
fi
if [ "$arg" = "compose" ]; then
NEXT_GOT="true"
fi
done
fi
# Check if the subcommand is blocked
if [ -n "$SUBCOMMAND" ]; then
if echo "$SUBCOMMAND" | grep -qE "$BLOCKED_PATTERN"; then
echo "ERROR: docker '$SUBCOMMAND' is blocked by security policy" >&2
echo "This command can modify files inside containers." >&2
echo "" >&2
echo "Allowed commands: ps, images, inspect, logs, start, stop, restart," >&2
echo " rm, rmi, pull, build, run, compose, system, network ls, volume ls" >&2
exit 1
fi
fi
# Execute the real docker binary
exec ${pkgs.docker}/bin/docker "$@"
'';
in
{
options.services.aiWorkerAccess = mkOption {
type = types.bool;
default = false;
description = "Enable AI worker SSH access with restricted docker commands";
description = "Enable AI worker SSH access with restricted sudo docker commands";
};
config = mkIf config.services.aiWorkerAccess {
# ai-worker is in docker group for normal docker operations
users.groups.docker.members = [ "ai-worker" ];
# Install the docker wrapper for ai-worker
# This puts a filtered 'docker' script in ai-worker's PATH that blocks
# dangerous commands like exec, cp, commit, etc.
# The real docker binary is still available at its store path, but the
# wrapper intercepts it because ~/.nix-profile/bin/ comes before /run/.../sw/bin/ in PATH.
users.users.ai-worker.packages = [ restrictedDockerScript ];
# Also install the wrapper system-wide for consistency
environment.systemPackages = [ restrictedDockerScript ];
# SECURITY: ai-worker is NOT added to docker group.
# Docker access is granted via sudo whitelist in users/ai-worker.nix.
# This prevents unrestricted docker daemon access (docker exec, cp, commit, etc.)
# Only specific docker subcommands are allowed via sudo NOPASSWD rules.
# The old approach (docker group membership) has been removed because:
# - Docker group gives UNRESTRICTED access to the docker daemon socket
# - No way to limit which docker subcommands a docker group member can run
# - Allowed: docker exec, docker cp, docker run -v /:/host, etc.
# users.groups.docker.members = [ "ai-worker" ]; // REMOVED
};
}

View File

@@ -4,9 +4,9 @@
group = "ai-worker";
home = "/home/ai-worker";
createHome = true;
# ai-worker stays in docker group for normal docker operations (ps, start, stop, compose, ...)
# Dangerous commands (exec, cp, commit) are blocked by a wrapper script.
extraGroups = [ "docker" ];
# SECURITY: ai-worker is NOT in the docker group.
# Docker access is restricted via sudo whitelist — only specific subcommands allowed.
# extraGroups = [ "docker" ]; — REMOVED: docker group gives unrestricted docker daemon access
shell = pkgs.bashInteractive;
openssh.authorizedKeys.keys = [
keys.users.ai-worker.main
@@ -16,19 +16,137 @@
};
users.groups.ai-worker = {};
# Enable restricted AI worker SSH access
# SECURITY: ai-worker is in docker group but docker commands are filtered:
# ALLOWED: ps, images, logs, start, stop, restart, rm, rmi, pull, build, run, compose
# BLOCKED: exec, cp, commit, diff, export, import, load, save, attach, push
# The filtering is done by a docker wrapper in ai-worker's PATH.
# Enable restricted AI worker SSH access for ollama benchmarking
# SECURITY: ai-worker can only:
# - SSH into host from Hermes container
# - Run docker commands via sudo (whitelist below — no exec/cp/commit)
# - Run specific security audit commands
# - NO access to infra repo (no bind mount)
# - NO nix/nixos-rebuild/nh commands
# WORKFLOW: SSH from Hermes container, run docker commands via sudo, return and save results
services.aiWorkerAccess = true;
# Restricted sudo for ai-worker - security checks only (not for docker)
# Restricted sudo for ai-worker
# IMPORTANT: ai-worker is NOT in docker group. All docker access goes through sudo.
# Only the subcommands listed below are allowed — everything else is denied.
# This prevents: docker exec, docker cp, docker commit, and other file-modifying operations.
security.sudo.extraRules = [
{
users = [ "ai-worker" ];
commands = [
# Firewall checks
# === Docker commands: lifecycle management (NO file modification) ===
# ps/inspect/logs — read-only status checks
{
command = "/run/current-system/sw/bin/docker ps";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/docker inspect *";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/docker logs *";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/docker images";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/docker info";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/docker version";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/docker stats *";
options = [ "NOPASSWD" ];
}
# start/stop/restart — container lifecycle
{
command = "/run/current-system/sw/bin/docker start *";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/docker stop *";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/docker restart *";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/docker rm *";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/docker rmi *";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/docker wait *";
options = [ "NOPASSWD" ];
}
# pull/build/run — image management and container creation
{
command = "/run/current-system/sw/bin/docker pull *";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/docker build *";
options = [ "NOPASSWD" ];
}
{
command = "/run/current-system/sw/bin/docker run *";
options = [ "NOPASSWD" ];
}
# compose — orchestration
{
command = "/run/current-system/sw/bin/docker compose *";
options = [ "NOPASSWD" ];
}
# system — disk cleanup
{
command = "/run/current-system/sw/bin/docker system *";
options = [ "NOPASSWD" ];
}
# network — list only (create/modify not needed)
{
command = "/run/current-system/sw/bin/docker network ls";
options = [ "NOPASSWD" ];
}
# volume — list only (create/modify not needed)
{
command = "/run/current-system/sw/bin/docker volume ls";
options = [ "NOPASSWD" ];
}
# === EXPLICITLY DENIED docker commands (not in whitelist — sudo rejects them) ===
# docker exec — executes arbitrary commands inside running containers (FILE MODIFICATION)
# docker cp — copies files between containers and host (FILE ACCESS)
# docker commit — creates images from running containers (DATA EXFIL)
# docker diff — inspects filesystem changes (INFO LEAK)
# docker export — exports container filesystem (DATA EXFIL)
# docker import — imports filesystem archives
# docker load — loads docker images
# docker save — saves docker images to tar (DATA EXFIL)
# docker attach — attaches to running containers (INTERACTIVE ACCESS)
# docker push — pushes images to registries (DATA EXFIL)
# docker tag — renames images
# docker create — creates containers (use 'docker run' instead)
# docker plugin — manages plugins
# docker network create/rm — network management
# docker volume create/rm — volume management
# === Firewall checks ===
{
command = "/run/wrappers/bin/sudo iptables -L -n -v";
options = [ "NOPASSWD" ];
@@ -37,7 +155,8 @@
command = "/run/wrappers/bin/sudo iptables -S";
options = [ "NOPASSWD" ];
}
# Fail2ban status
# === Fail2ban status ===
{
command = "/run/current-system/sw/bin/fail2ban-client status";
options = [ "NOPASSWD" ];
@@ -50,7 +169,8 @@
command = "/run/current-system/sw/bin/fail2ban-client get * banned";
options = [ "NOPASSWD" ];
}
# Log inspection
# === Log inspection ===
{
command = "/run/current-system/sw/bin/journalctl -t kernel -n 100";
options = [ "NOPASSWD" ];
@@ -63,12 +183,14 @@
command = "/run/current-system/sw/bin/journalctl -u firewall -n 50";
options = [ "NOPASSWD" ];
}
# SSH config verification
# === SSH config verification ===
{
command = "/run/current-system/sw/bin/sshd -T";
options = [ "NOPASSWD" ];
}
# Network diagnostics
# === Network diagnostics ===
{
command = "/run/current-system/sw/bin/ss -tlnp";
options = [ "NOPASSWD" ];