> ## Documentation Index
> Fetch the complete documentation index at: https://docs.runlayer.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Container (Detect only)

> Deploy AI Watch as a container that scans bind-mounted host user homes — standalone docker run or a Kubernetes DaemonSet / CronJob. Detect only; Enforce and Sessions are not available.

AI Watch ships as an amd64 container image that runs the same **Detect-only all-users scanner** as the Linux `.deb` / `.rpm`, containerized. Instead of a cron entry on the host, the image's entrypoint (`scan-host-users.sh`) scans the **host user homes that are bind-mounted into the container**, reading `/etc/passwd` to enumerate every account and running one privilege-dropped scan per user home.

Use this when you'd rather ship a container than a native package — for example on Kubernetes nodes, or hosts already managed as container workloads. For native-package and distro context, see [Linux (Detect only)](/shadow-ai/deploy/linux).

## What it is

* The **same Detect-only all-users scanner** as the Linux package: it enumerates **all** passwd users with an existing (bind-mounted) home — root and service accounts included, shared homes deduped — and reports MCP servers, connectors/plugins, skills, and agents to your Runlayer tenant.
* **Not a sandboxed app.** Like the `.deb`/cron model, it needs to read every user's home directory, so it requires a privileged read-only mount of the host filesystem. It is not a self-contained workload that only sees its own filesystem.
* **Detect only.** There are no hooks, no Enforce, no Sessions, and no browser extension. In the dashboard the device shows **Detect = Active** and **Enforce / Sessions = Disabled** — the expected state, not an error. (The image bakes `/etc/runlayer/aiwatch/config.json` with `Sessions`/`Enforcement` `false`, so this state is guaranteed without any mounted config.)
* **Local passwd only.** The container enumerates users from the mounted host `/etc/passwd`. Centrally-managed accounts that exist only via NSS/SSSD/LDAP (not written to `/etc/passwd`) are **not** enumerated — even if their homes are mounted — because the container can't resolve the host's NSS stack. This differs from the native `.deb`/`.rpm` scanner, which runs `getent passwd` and so resolves NSS/LDAP users. If your fleet uses centrally-managed accounts, generate full snapshots on the host (`getent passwd > /etc/runlayer/aiwatch/passwd-snapshot` **and** `getent group > /etc/runlayer/aiwatch/group-snapshot`, via a host cron) and mount them as `RUNLAYER_HOST_PASSWD` / `RUNLAYER_HOST_GROUP`. The group snapshot matters: supplementary groups are restored from the group file before the privilege drop, so without it NSS/LDAP-only memberships are lost and scans run with the primary GID only — missing group-readable homes and shared project dirs the native scanner would read.

## Image

The scanner image is published to Runlayer's private CustomerDistribution ECR and referenced as:

```
<ECR_REGISTRY>/runlayer-aiwatch:<version>
```

Pull it per your Runlayer downloads access — contact your Runlayer account team if you don't have registry access yet. There is no public image URL.

* **amd64 only.** The bundled `aiwatch` binary is x86\_64 (built on `manylinux_2_28`), so the whole image is `linux/amd64`. On an arm64 host it runs under emulation (Rosetta / qemu).
* The glibc floor is moot: the runtime is `debian:12-slim` and the bundle is self-contained, so there is no host glibc requirement — unlike the `.deb` / `.rpm`, which have a host glibc floor (see [Linux (Detect only)](/shadow-ai/deploy/linux) for the current support matrix).

## Standalone `docker run`

Mount only the paths the scanner reads, each read-only under `/host` — **not** the whole host root. `RUNLAYER_HOST_HOME_PREFIX=/host` makes the host-absolute passwd homes (`/home/alice`) resolve to the mounted copies inside the container. This covers users under `/home` and `/root`; homes elsewhere (service accounts under `/var/lib`, custom `/srv` homes) are **not silently missed** — the scanner logs each skipped user and a `scanned/skipped` summary each pass, so you add the prefix if you need it (see below).

```bash theme={null}
docker run --rm \
  -e RUNLAYER_API_KEY=rl_org_xxxxxxxxxxxxxxxx \
  -e RUNLAYER_HOST=https://tenant.runlayer.com \
  -e RUNLAYER_HOST_PASSWD=/host/etc/passwd \
  -e RUNLAYER_HOST_HOME_PREFIX=/host \
  -e RUNLAYER_MACHINE_ID_PATH=/host/etc/machine-id \
  -e RUNLAYER_HOSTNAME="$(hostname)" \
  -v /home:/host/home:ro \
  -v /root:/host/root:ro \
  -v /etc/passwd:/host/etc/passwd:ro \
  -v /etc/group:/host/etc/group:ro \
  -v /etc/machine-id:/host/etc/machine-id:ro \
  <ECR_REGISTRY>/runlayer-aiwatch:<version>
```

By default the container loops forever: scan, sleep `RUNLAYER_SCAN_INTERVAL` seconds (default `900` = 15 min), repeat. A failing pass is logged and the next pass retries — the container does not exit.

For a **one-shot** pass (single scan then exit — e.g. driven by a host cron entry or a systemd timer that runs `docker run` on a schedule), pass `--once` or set `RUNLAYER_RUN_ONCE=1`:

```bash theme={null}
docker run --rm \
  -e RUNLAYER_API_KEY=rl_org_xxxxxxxxxxxxxxxx \
  -e RUNLAYER_HOST=https://tenant.runlayer.com \
  -e RUNLAYER_HOST_PASSWD=/host/etc/passwd \
  -e RUNLAYER_HOST_HOME_PREFIX=/host \
  -e RUNLAYER_MACHINE_ID_PATH=/host/etc/machine-id \
  -e RUNLAYER_HOSTNAME="$(hostname)" \
  -v /home:/host/home:ro \
  -v /root:/host/root:ro \
  -v /etc/passwd:/host/etc/passwd:ro \
  -v /etc/group:/host/etc/group:ro \
  -v /etc/machine-id:/host/etc/machine-id:ro \
  <ECR_REGISTRY>/runlayer-aiwatch:<version> --once
```

Without `RUNLAYER_API_KEY` the entrypoint exits `0` quietly before any scan (the unconfigured-fleet gate), so an unconfigured container produces no errors and no data.

One honesty note on `:ro`: **read-only bind mounts are not recursive** — if `/home` itself contains mounted filesystems (NFS/EBS/tmpfs homes), those submounts can remain writable through the mount, on both Docker and Kubernetes. The only writes the scanner performs there are its per-user `~/.runlayer` state (logs) — the same writes the native `.deb` scanner does by design — so the exposure is benign, but don't read `:ro` as a hard guarantee for submounted homes. On Kubernetes 1.30+ you can opt into `recursiveReadOnly` (commented in the manifests) to close this.

Need to cover homes outside `/home` and `/root`? Add the prefix as another read-only mount (e.g. `-v /var/lib:/host/var/lib:ro`) — keep the scope as narrow as your passwd layout allows. As a last resort you can mount the whole root (`-v /:/host:ro`) for maximal coverage, but that exposes far more of the host to the container than the scanner needs; prefer explicit prefixes. Either way the entrypoint logs any skipped (unmounted) user and warns when a pass finds zero scannable homes — the usual sign of a mount/prefix mismatch.

### Environment

| Variable                    | Default            | Purpose                                                                                                                                                                                                                                  |
| --------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RUNLAYER_API_KEY`          | — (required)       | Org API key (`rl_org_...`, **Shadow AI Scan** role). Empty ⇒ quiet exit 0.                                                                                                                                                               |
| `RUNLAYER_HOST`             | —                  | Tenant API endpoint (e.g. `https://tenant.runlayer.com`).                                                                                                                                                                                |
| `RUNLAYER_HOST_PASSWD`      | `/host/etc/passwd` | Host passwd file to enumerate. Falls back to the container's own `getent passwd` if unreadable.                                                                                                                                          |
| `RUNLAYER_HOST_GROUP`       | `/host/etc/group`  | Host group file; restores each user's supplementary groups on the privilege drop so group-readable files stay scannable. Missing ⇒ primary group only.                                                                                   |
| `RUNLAYER_HOST_HOME_PREFIX` | `""` (empty)       | Prefix prepended to passwd home paths. Set to `/host` to pair with the `/host` root mount. The entrypoint exports it as the strip prefix so submitted findings show real **host** paths, not `/host/...` container paths.                |
| `RUNLAYER_MACHINE_ID_PATH`  | (unset)            | Path to the host machine-id, so the device id matches the host.                                                                                                                                                                          |
| `RUNLAYER_HOSTNAME`         | detected           | Overrides the reported hostname for device attribution. **Set this on standalone `docker run`** (e.g. `"$(hostname)"`) — otherwise the device shows the random container ID, not the host. K8s sets it from the node name automatically. |
| `RUNLAYER_DEVICE_ID`        | (unset)            | Overrides the device id outright (takes priority over machine-id).                                                                                                                                                                       |
| `RUNLAYER_SCAN_INTERVAL`    | `900`              | Loop sleep seconds between passes (steady-state / DaemonSet).                                                                                                                                                                            |
| `RUNLAYER_RUN_ONCE`         | (unset)            | `1` ⇒ single pass then exit (same as the `--once` arg).                                                                                                                                                                                  |
| `RUNLAYER_SCAN_TIMEOUT`     | `600`              | Per-user scan timeout in seconds.                                                                                                                                                                                                        |

<Warning>
  **SELinux (RHEL / Fedora / Rocky / Alma):** with SELinux enforcing, bind-mounted host files are unreadable to the container by default and scans return nothing. With the whole-root mount, **never use `:z`/`:Z`** — those relabel the bind *source*, i.e. the entire host filesystem, and Docker's own documentation warns that relabeling system directories can make the host unusable. Run with `--security-opt label=disable` instead (a per-container opt-out that leaves host labels untouched), or ship a custom SELinux policy. `:z` is only reasonable on narrow per-directory mounts you own.
</Warning>

## Kubernetes

Ready-to-apply manifests live in [`cli/packaging/container/k8s/`](https://github.com/runlayer/Runlayer/tree/main/cli/packaging/container/k8s) (`namespace`, `configmap`, `secret.example`, `daemonset`, `cronjob`, `kustomization`, plus a `README`). Each pod bind-mounts the **node's** host user homes read-only under `/host` and attributes results to the node.

```sh theme={null}
# 1. Namespace (skip if reusing an existing one).
kubectl apply -f namespace.yaml

# 2. API key Secret — create imperatively so the key never lands in git.
kubectl -n runlayer create secret generic runlayer-aiwatch \
  --from-literal=RUNLAYER_API_KEY=rl_org_xxxxxxxxxxxxxxxx

# 3. Image pull access. The image is in the private CustomerDistribution ECR.
#    PREFER node-level ECR access (an EKS node role with
#    AmazonEC2ContainerRegistryReadOnly, or a kubelet credential provider) —
#    it never expires and needs no secret; skip this step if you have it.
#    If you must use a pull secret, note the ECR authorization token is only
#    valid for 12 HOURS: a one-time secret works for the first pulls, then
#    new nodes, image GC, or rescheduling hit ImagePullBackOff. Rotate it
#    (a small CronJob re-running the command below, or External Secrets /
#    an ECR credential helper) and uncomment `imagePullSecrets` in the
#    DaemonSet/CronJob:
#      kubectl -n runlayer create secret docker-registry runlayer-aiwatch-ecr \
#        --docker-server=<ECR_REGISTRY> \
#        --docker-username=AWS \
#        --docker-password="$(aws ecr get-login-password --region <region>)"

# 4. Substitute the real image. Edit `images:` in kustomization.yaml, e.g.
#      newName: <ECR_REGISTRY>/runlayer-aiwatch
#      newTag: "<version>"

# 5. Point the scanner at YOUR tenant: set RUNLAYER_HOST in configmap.yaml
#    (or a kustomize overlay) to the host your org API key was minted for.
#    With the checked-in default the pods deploy fine but submit to the wrong
#    backend and no devices ever appear.

# 6. Apply everything (defaults to the DaemonSet):
kubectl apply -k .
```

The `RUNLAYER_HOST`, `RUNLAYER_HOST_PASSWD=/host/etc/passwd`, `RUNLAYER_HOST_HOME_PREFIX=/host`, and `RUNLAYER_MACHINE_ID_PATH=/host/etc/machine-id` values come from the ConfigMap; `RUNLAYER_API_KEY` comes from the Secret. All host mounts are `readOnly: true` under `/host`, matching those env paths.

### DaemonSet vs CronJob — pick ONE

* **DaemonSet (recommended):** one long-running pod per node that loops on `RUNLAYER_SCAN_INTERVAL` (default 900s). This is the only shape that gives fleet-wide **per-node** coverage automatically. Scope it with `nodeSelector` / `tolerations`.
* **CronJob:** schedules a single `--once` Job every 15 min (`concurrencyPolicy: Forbid`). A CronJob does **not** run per-node — its pod lands on one scheduler-chosen node, so only that node is scanned each tick. Use it for single-node clusters, or pin it to a node via `nodeSelector` (one CronJob per node you want covered).

<Warning>
  **Run one, not both.** The DaemonSet and CronJob scan the same node-local homes and report under the same device id (the host machine-id), so running both **double-reports every node**. Deploy exactly one shape.
</Warning>

### Node attribution

`RUNLAYER_HOSTNAME` is set from the downward API `spec.nodeName`, so scans attribute to the **node** rather than the ephemeral pod name. `RUNLAYER_MACHINE_ID_PATH=/host/etc/machine-id` points device-id derivation at the bind-mounted host machine-id, so the device id also matches the node.

The container runs as root (uid 0) because the entrypoint drops to each host user's **numeric** uid via `setpriv` per scan child; it is not privileged and needs no host network or PID. It reads only the mounted files — never the Kubernetes API — so no RBAC is required (`automountServiceAccountToken: false`).

<Warning>
  **SELinux / OpenShift:** on RHEL/Fedora/OpenShift nodes, reading host `user_home_t` files from a container is blocked by default. The exact fix is cluster-specific, so it is not hardcoded — grant an SCC that permits the `hostPath` mounts and set a `securityContext.seLinuxOptions.type` valid on your cluster (the manifests ship a commented `seLinuxOptions` block to uncomment), or label the node dirs. This is the K8s equivalent of `:z`/`:Z` on standalone `docker run`.
</Warning>

## Device identity — don't run two delivery models per host

The **machine-id is the dedup key**: AI Watch derives the device id from the host machine-id, so every user on a host maps to one device. Bind-mount `/etc/machine-id` (`RUNLAYER_MACHINE_ID_PATH=/host/etc/machine-id`) — or set `RUNLAYER_DEVICE_ID` explicitly — so the container's device id matches the host.

<Warning>
  **Do not run both the `.deb`/cron package and the container on the same host.** They scan the same user homes and report under the same device id, so the host is counted twice. Pick **one delivery model per host** — the native package ([Linux](/shadow-ai/deploy/linux)) or the container, not both.
</Warning>

## Verify

On a test host, seed a shadow-AI config in a user's home and run one pass:

```bash theme={null}
# 1. Seed a shadow MCP server for user alice.
sudo -u alice mkdir -p ~alice/.cursor
sudo -u alice tee ~alice/.cursor/mcp.json >/dev/null <<'JSON'
{ "mcpServers": { "demo": { "command": "npx", "args": ["-y", "demo-mcp"] } } }
JSON

# 2. Run a single scan pass in the container.
docker run --rm \
  -e RUNLAYER_API_KEY=rl_org_xxxxxxxxxxxxxxxx \
  -e RUNLAYER_HOST=https://tenant.runlayer.com \
  -e RUNLAYER_HOST_PASSWD=/host/etc/passwd \
  -e RUNLAYER_HOST_HOME_PREFIX=/host \
  -e RUNLAYER_MACHINE_ID_PATH=/host/etc/machine-id \
  -e RUNLAYER_HOSTNAME="$(hostname)" \
  -v /home:/host/home:ro \
  -v /root:/host/root:ro \
  -v /etc/passwd:/host/etc/passwd:ro \
  -v /etc/group:/host/etc/group:ro \
  -v /etc/machine-id:/host/etc/machine-id:ro \
  <ECR_REGISTRY>/runlayer-aiwatch:<version> --once
```

Then in the Runlayer dashboard:

* **Shadow AI → Devices** shows the host with **os = Linux**, **Detect = Active**, **Enforce / Sessions = Disabled** (the correct Detect-only state).
* **Connectors** shows alice's shadow MCP server.

On Kubernetes, `kubectl -n runlayer logs` on the DaemonSet pod (or the CronJob's Job pod) shows a `scanning user=... home=/host/home/...` line per host user; the node appears in **Shadow AI → Devices** the same way.

## Related

<CardGroup cols={2}>
  <Card title="Linux (Detect only)" icon="linux" href="/shadow-ai/deploy/linux">
    Native `.deb` / `.rpm` packages, supported distributions, and cron model.
  </Card>

  <Card title="Deploy AI Watch" icon="rocket" href="/shadow-ai/deploy">
    Full deployment overview across macOS, Windows, and Linux.
  </Card>
</CardGroup>
