Skip to main content
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).

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) for the current support matrix).

Standalone docker run

Mount only the paths the scanner reads, each read-only under /hostnot 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).
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:
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

VariableDefaultPurpose
RUNLAYER_API_KEY— (required)Org API key (rl_org_..., Shadow AI Scan role). Empty ⇒ quiet exit 0.
RUNLAYER_HOSTTenant API endpoint (e.g. https://tenant.runlayer.com).
RUNLAYER_HOST_PASSWD/host/etc/passwdHost passwd file to enumerate. Falls back to the container’s own getent passwd if unreadable.
RUNLAYER_HOST_GROUP/host/etc/groupHost 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_HOSTNAMEdetectedOverrides 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_INTERVAL900Loop sleep seconds between passes (steady-state / DaemonSet).
RUNLAYER_RUN_ONCE(unset)1 ⇒ single pass then exit (same as the --once arg).
RUNLAYER_SCAN_TIMEOUT600Per-user scan timeout in seconds.
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.

Kubernetes

Ready-to-apply manifests live in 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.
# 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).
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.

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).
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.

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.
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) or the container, not both.

Verify

On a test host, seed a shadow-AI config in a user’s home and run one pass:
# 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.

Linux (Detect only)

Native .deb / .rpm packages, supported distributions, and cron model.

Deploy AI Watch

Full deployment overview across macOS, Windows, and Linux.