# ==================== # Stage 1: Build nsjail from source # ==================== FROM debian:bookworm-slim AS nsjail-builder ENV DEBIAN_FRONTEND=noninteractive # Pin NsJail 4.0 source with the in-process NSTUN policy backend. ENV NSJAIL_COMMIT=388b9655a696e88df3185d09e36c0378a8532904 RUN apt-get update && apt-get install -y --no-install-recommends \ git gcc g++ make pkg-config bison flex \ libprotobuf-dev protobuf-compiler libnl-route-3-dev ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY docker/nsjail/nstun-bounded-memory.patch /tmp/nstun-bounded-memory.patch RUN git clone https://github.com/google/nsjail.git /tmp/nsjail && \ cd /tmp/nsjail && git checkout "${NSJAIL_COMMIT}" && \ git apply --check /tmp/nstun-bounded-memory.patch && \ git apply /tmp/nstun-bounded-memory.patch && \ git submodule update --init --recursive && \ make -j"$(nproc)" && \ install -m 0755 nsjail /usr/local/bin/nsjail && \ rm -rf /tmp/nsjail COPY docker/loop-device-sync.cc /tmp/loop-device-sync.cc RUN g++ -std=c++20 -O2 -Wall -Wextra -Werror -Wformat=2 \ -D_FORTIFY_SOURCE=2 -fstack-protector-strong -fPIE -pie \ -Wl,-z,relro,-z,now \ /tmp/loop-device-sync.cc -o /usr/local/bin/tracecat-loop-device-sync && \ strip /usr/local/bin/tracecat-loop-device-sync && \ rm /tmp/loop-device-sync.cc # ==================== # Stage 2: Create minimal sandbox rootfs # ==================== FROM node:22.23.4-slim AS node-bin FROM python:3.12-slim-bookworm AS sandbox-rootfs ARG TARGETARCH ARG DUCKDB_VERSION=2.0.0 RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl wget jq iputils-ping git openssh-client squashfs-tools \ && rm -rf /var/lib/apt/lists/* # The slim base ships the stdlib without bytecode, and sandboxes mount this # rootfs read-only, so every sandboxed Python process would recompile the # stdlib from source. Checked hash-based .pyc files stay valid whatever mtimes # the later COPY steps leave on the sources. Python still hashes each source # on import, so an image built on this one that patches a stdlib module never # runs stale bytecode. RUN python -m compileall -q -j0 --invalidation-mode checked-hash \ "$(python -c 'import sysconfig; print(sysconfig.get_path("stdlib"))')" # This rootfs is shared by run_python and agent sandboxes; CLI additions here # are intentionally available to both. DuckDB is not available from Bookworm # apt, so install the official release binary, verify it, preinstall extensions # (json is built in since 2.0), and wrap the CLI to load those extensions on # each invocation. # # Both the CLI binary and every extension are pinned by sha256: extensions are # downloaded from the version-pinned repository, verified, and installed from # local files (DuckDB still validates the signed extensions on load) instead of # resolving "latest for this version" via a bare INSTALL. RUN set -eu; \ arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ case "${arch}" in \ amd64) \ platform="linux_amd64"; \ duckdb_sha256="c6507206baf7793c6013dbd82368469a45f20dc0e17a6f92f204602f652ed2bc"; \ ext_shas="httpfs:ee6c7d7e60a5da1b6f6bf6156b4298b55ddce0496a5bfe628bd6ab6d47eb8e3e inet:afc4efe65dfb5106b18e3ab75753f680bd6eecc5d17ec347a325b3bf0e55cfdd fts:9172520652ce609d163cf30645f382b197f2c7294448a816c77d62b0f029d96e"; \ ;; \ arm64) \ platform="linux_arm64"; \ duckdb_sha256="859e1875d2a9515fbb4bf8871061437a14af82320da68c753f094c4b776348fe"; \ ext_shas="httpfs:f524888fc7d74cc0c6c4a8f7ed5beedac5b12e71cb426503f882c72ce6afbd43 inet:fa189a8adaa657299d05b8f99a181b10223f5063c396514dea06f8219ff646ab fts:d233d2311825da4d9b6efb45edd52b126b18134c7d7083b1e58f6d450ebdabee"; \ ;; \ *) echo "Unsupported DuckDB CLI architecture: ${arch}" >&2; exit 1 ;; \ esac; \ curl -fsSL "https://github.com/duckdb/duckdb/releases/download/v${DUCKDB_VERSION}/duckdb-cli-linux-${arch}.tar.gz" -o /tmp/duckdb.tar.gz; \ echo "${duckdb_sha256} /tmp/duckdb.tar.gz" | sha256sum -c -; \ tar -xzf /tmp/duckdb.tar.gz -C /tmp duckdb; \ rm -f /tmp/duckdb.tar.gz; \ install -m 0755 /tmp/duckdb /usr/local/bin/duckdb.real; \ rm -f /tmp/duckdb; \ mkdir -p /usr/local/lib/duckdb/extensions /usr/local/share/duckdb /tmp/ddbext; \ install_args=""; \ for spec in ${ext_shas}; do \ name="${spec%%:*}"; sha="${spec##*:}"; \ curl -fsSL "https://extensions.duckdb.org/v${DUCKDB_VERSION}/${platform}/${name}.duckdb_extension.gz" -o "/tmp/ddbext/${name}.duckdb_extension.gz"; \ echo "${sha} /tmp/ddbext/${name}.duckdb_extension.gz" | sha256sum -c -; \ gunzip "/tmp/ddbext/${name}.duckdb_extension.gz"; \ install_args="${install_args} INSTALL '/tmp/ddbext/${name}.duckdb_extension';"; \ done; \ /usr/local/bin/duckdb.real -c "SET extension_directories = ['/usr/local/lib/duckdb/extensions'];${install_args}"; \ rm -rf /tmp/ddbext; \ printf '%s\n' \ "SET extension_directories = ['/usr/local/lib/duckdb/extensions'];" \ "LOAD httpfs;" \ "LOAD inet;" \ "LOAD fts;" \ > /usr/local/share/duckdb/tracecat-init.sql; \ printf '%s\n' \ '#!/bin/sh' \ 'exec /usr/local/bin/duckdb.real -init /usr/local/share/duckdb/tracecat-init.sql "$@"' \ > /usr/local/bin/duckdb; \ chmod 0755 /usr/local/bin/duckdb; \ jq --version; \ duckdb --version; \ test "$(duckdb -csv -noheader -c "SELECT count(*) FROM duckdb_extensions() WHERE extension_name IN ('json', 'httpfs', 'inet', 'fts') AND installed AND loaded;")" = "4" COPY --from=ghcr.io/astral-sh/uv:0.13.4 /uv /usr/local/bin/uv COPY --from=ghcr.io/astral-sh/uv:0.13.4 /uvx /usr/local/bin/uvx COPY --from=node-bin /usr/local/bin/node /usr/local/bin/node COPY --from=node-bin /usr/local/lib/node_modules /usr/local/lib/node_modules RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx RUN useradd -m -u 1000 sandbox && \ mkdir -p /workspace /work /cache /packages /home/sandbox && \ chown sandbox:sandbox /workspace /work /cache /packages /home/sandbox ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 # ==================== # Stage 3: Shared base with runtime dependencies # ==================== FROM python:3.12-slim-bookworm AS base COPY --from=ghcr.io/astral-sh/uv:0.13.4 /uv /uvx /usr/local/bin/ ENV HOST=0.0.0.0 PORT=8000 # Copy nsjail binary COPY --from=nsjail-builder /usr/local/bin/nsjail /usr/local/bin/nsjail # Copy Node.js + npx for in-process MCP command servers (stdio) COPY --from=node-bin /usr/local/bin/node /usr/local/bin/node COPY --from=node-bin /usr/local/lib/node_modules /usr/local/lib/node_modules RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx # Install runtime packages RUN apt-get update && apt-get install -y --no-install-recommends \ acl git openssh-client xmlsec1 libmagic1 curl ca-certificates jq \ libnl-route-3-200 libprotobuf32 libcap2-bin util-linux \ squashfs-tools \ && apt-get -y upgrade \ && apt-get clean && rm -rf /var/lib/apt/lists/* # DuckDB (CLI binary + extensions) is stored as a single physical copy inside # the sandbox rootfs (copied in below). The executor host — including the # in-process DuckDB Python package used by core.duckdb.execute_sql — reaches it # through symlinks created right after that copy, so the large extension # binaries are not stored twice in the image. The Python package loads # extensions from this directory instead of autoinstalling over the network. ENV TRACECAT__DUCKDB_EXTENSION_DIRECTORY=/usr/local/lib/duckdb/extensions # Copy sandbox rootfs. --chmod=755 normalizes every copied path and drops # setuid/setgid bits at copy time; a recursive chmod after the copy would store # a second full copy of the rootfs in its own layer. COPY --from=sandbox-rootfs --chmod=755 /usr /var/lib/tracecat/sandbox-rootfs/usr # Expose the single rootfs DuckDB copy to the executor host via symlinks, so the # host CLI and the in-process DuckDB Python package work without a second copy # of the extension binaries. The nsjail sandbox uses its own (physical) rootfs # copy and is unaffected by these host-side links. RUN ln -s /var/lib/tracecat/sandbox-rootfs/usr/local/lib/duckdb /usr/local/lib/duckdb && \ ln -s /var/lib/tracecat/sandbox-rootfs/usr/local/share/duckdb /usr/local/share/duckdb && \ ln -s /var/lib/tracecat/sandbox-rootfs/usr/local/bin/duckdb.real /usr/local/bin/duckdb.real && \ ln -s /var/lib/tracecat/sandbox-rootfs/usr/local/bin/duckdb /usr/local/bin/duckdb && \ jq --version && duckdb --version COPY --from=sandbox-rootfs --chmod=755 /lib /var/lib/tracecat/sandbox-rootfs/lib COPY --from=sandbox-rootfs --chmod=755 /bin /var/lib/tracecat/sandbox-rootfs/bin COPY --from=sandbox-rootfs --chmod=755 /sbin /var/lib/tracecat/sandbox-rootfs/sbin COPY --from=sandbox-rootfs --chmod=755 /etc/passwd /var/lib/tracecat/sandbox-rootfs/etc/passwd COPY --from=sandbox-rootfs --chmod=755 /etc/group /var/lib/tracecat/sandbox-rootfs/etc/group COPY --from=sandbox-rootfs --chmod=755 /etc/ssl /var/lib/tracecat/sandbox-rootfs/etc/ssl COPY --from=sandbox-rootfs --chmod=755 /etc/ca-certificates /var/lib/tracecat/sandbox-rootfs/etc/ca-certificates RUN install -m 0644 /dev/null /var/lib/tracecat/sandbox-rootfs/etc/resolv.conf && \ install -m 0644 /dev/null /var/lib/tracecat/sandbox-rootfs/etc/hosts && \ install -m 0644 /dev/null /var/lib/tracecat/sandbox-rootfs/etc/nsswitch.conf # Handle lib64 for amd64 RUN if [ -d /var/lib/tracecat/sandbox-rootfs/lib64 ] || [ "$(uname -m)" = "x86_64" ]; then \ mkdir -p /var/lib/tracecat/sandbox-rootfs/lib64 && \ cp -a /lib64/. /var/lib/tracecat/sandbox-rootfs/lib64/ 2>/dev/null || true; \ fi # Create sandbox directories RUN mkdir -p /var/lib/tracecat/sandbox-rootfs/tmp \ /var/lib/tracecat/sandbox-rootfs/proc \ /var/lib/tracecat/sandbox-rootfs/dev \ /var/lib/tracecat/sandbox-rootfs/work \ /var/lib/tracecat/sandbox-rootfs/cache \ /var/lib/tracecat/sandbox-rootfs/packages \ /var/lib/tracecat/sandbox-rootfs/home/sandbox \ /var/lib/tracecat/sandbox-cache/packages \ /var/lib/tracecat/sandbox-cache/uv-cache && \ chown -R 1000:1000 /var/lib/tracecat/sandbox-rootfs/work \ /var/lib/tracecat/sandbox-rootfs/cache \ /var/lib/tracecat/sandbox-rootfs/packages \ /var/lib/tracecat/sandbox-rootfs/home/sandbox && \ chmod 1777 /var/lib/tracecat/sandbox-rootfs/tmp # Create apiuser for the non-root runtime. RUN groupadd -g 1001 apiuser && useradd -m -u 1001 -g apiuser apiuser && \ mkdir -p /home/apiuser/.cache/uv /home/apiuser/.cache/s3 /home/apiuser/.cache/tmp /home/apiuser/.local/bin && \ chown -R apiuser:apiuser /home/apiuser # Create MCP socket directory for apiuser RUN mkdir -p /var/run/tracecat && chown 1001:1001 /var/run/tracecat # Allow the non-root executor process to invoke mount/umount and synchronize # kernel-confirmed loop nodes when the runtime grants the needed bounding # capabilities. The fixed-purpose sync helper cannot create arbitrary devices. COPY --from=nsjail-builder /usr/local/bin/tracecat-loop-device-sync /usr/local/bin/tracecat-loop-device-sync RUN chmod u-s /usr/bin/mount /usr/bin/umount && \ setcap cap_sys_admin,cap_dac_override+ep /usr/bin/mount && \ setcap cap_sys_admin,cap_dac_override+ep /usr/bin/umount && \ setcap cap_mknod,cap_dac_override,cap_setuid+ep /usr/local/bin/tracecat-loop-device-sync WORKDIR /app # ==================== # Stage 4: Fetch workspace-chat copilot skills # ==================== FROM base AS plugin-skills ARG TRACECAT_PLUGINS_REF=0140783181f0afcb69961661fa1474402ca46a4e ARG TRACECAT_PLUGINS_ARCHIVE_SHA256=408928d337e7330d10fd099d07c818d724def925eb6b77eaf0cb6ace2777f5dd # Pinned to a tracecat-plugins commit on main. Bump both values when the # vendored skills change so the trusted payload is verified before extraction. RUN set -eux; \ mkdir -p /skills /tmp/tracecat-plugins; \ curl -fsSL \ "https://github.com/TracecatHQ/tracecat-plugins/archive/${TRACECAT_PLUGINS_REF}.tar.gz" \ -o /tmp/tracecat-plugins.tar.gz; \ echo "${TRACECAT_PLUGINS_ARCHIVE_SHA256} /tmp/tracecat-plugins.tar.gz" \ | sha256sum -c -; \ tar -xzf /tmp/tracecat-plugins.tar.gz \ -C /tmp/tracecat-plugins \ --strip-components=1; \ for skill in \ tracecat-workspace-chat \ tracecat-automation-best-practices \ tracecat-slackbot-best-practices; do \ source="/tmp/tracecat-plugins/plugins/tracecat/skills/${skill}"; \ test -d "${source}"; \ test -f "${source}/SKILL.md"; \ cp -a "${source}" /skills/; \ done; \ test "$(find /skills -mindepth 1 -maxdepth 1 -type d | wc -l)" -eq 3; \ rm -rf /tmp/tracecat-plugins /tmp/tracecat-plugins.tar.gz # Workspace Chat reads platform guidance from docs built from this same commit. # Keep source structure so docs.json and MDX imports remain useful navigation. COPY ./docs /tmp/tracecat-docs RUN set -eux; \ docs_source=/tmp/tracecat-docs; \ docs_target=/skills/tracecat-workspace-chat/references/docs; \ mkdir -p "${docs_target}"; \ cd "${docs_source}"; \ find . -type f \( \ -name '*.mdx' -o \ -name 'docs.json' -o \ -path './automations/core-actions/_examples.yaml' -o \ -path './automations/core-actions/_manifest.yaml' \ \) -exec cp --parents '{}' "${docs_target}" \;; \ source_count="$(find "${docs_source}" -type f \( \ -name '*.mdx' -o \ -name 'docs.json' -o \ -path '*/automations/core-actions/_examples.yaml' -o \ -path '*/automations/core-actions/_manifest.yaml' \ \) | wc -l)"; \ target_count="$(find "${docs_target}" -type f | wc -l)"; \ test "${source_count}" -gt 0; \ test "${source_count}" -eq "${target_count}"; \ test -f "${docs_target}/docs.json"; \ test -f "${docs_target}/agents/workspace-chat.mdx"; \ test -f "${docs_target}/automations/workflows.mdx"; \ test -f "${docs_target}/automations/core-actions/_examples.yaml"; \ test -f "${docs_target}/automations/core-actions/_manifest.yaml"; \ test -z "$(find "${docs_target}" -type f \( \ -iname '*.gif' -o -iname '*.jpeg' -o -iname '*.jpg' -o \ -iname '*.png' -o -iname '*.svg' -o -iname '*.webp' \ \) -print -quit)"; \ rm -rf "${docs_source}" # ==================== # Stage 5: Prepare development source without bundled docs # ==================== FROM base AS development-source COPY . /source/ RUN rm -rf /source/docs # ==================== # Stage 6: Development app # ==================== FROM base AS development-app ENV TMPDIR="/home/apiuser/.cache/tmp" TEMP="/home/apiuser/.cache/tmp" TMP="/home/apiuser/.cache/tmp" # Set sandbox cache permissions for apiuser RUN chown -R 1001:1001 /var/lib/tracecat/sandbox-cache && \ chmod -R 755 /var/lib/tracecat/sandbox-cache RUN chown apiuser:apiuser /app # Switch to the non-root user used in production before installing, so /app # never needs a recursive chown (which would store a second copy of the venv). USER apiuser # Install third-party dependencies only, so this layer is reused until uv.lock or # pyproject.toml change. --frozen because uv cannot validate the lockfile # without workspace member sources; the sync below runs with --locked. RUN --mount=type=cache,target=/home/apiuser/.cache/uv,uid=1001,gid=1001 \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --frozen --no-install-workspace --no-dev --group admin --no-editable COPY --from=development-source --chown=apiuser:apiuser /source/ /app/ COPY --from=plugin-skills --chown=apiuser:apiuser /skills/ /var/lib/tracecat/copilot-skills/ RUN --mount=type=cache,target=/home/apiuser/.cache/uv,uid=1001,gid=1001 uv sync --locked --no-dev --group admin ENV PATH="/app/.venv/bin:$PATH" ENV PYTHONPATH="/home/apiuser/.local" RUN mkdir -p /home/apiuser/.local/bin && ln -s $(which uv) /home/apiuser/.local/bin/uv EXPOSE $PORT CMD ["sh", "-c", "python3 -m uvicorn tracecat.api.app:app --host $HOST --port $PORT --reload"] # ==================== # Stage 7: Development registry manifest # ==================== FROM development-app AS development-registry-manifest RUN /app/.venv/bin/python -m tracecat.registry.sync.prebuild # ==================== # Stage 8: Development target # ==================== FROM development-app AS development # Carry only the generated builtin registry metadata in dev images too, so local # cluster startup can update the DB without rediscovering actions on the hot path. COPY --from=development-registry-manifest --chown=apiuser:apiuser /app/.registry-artifacts /app/.registry-artifacts # ==================== # Stage 9: Test target (development + pytest) # ==================== FROM development AS test # Install test dependencies RUN --mount=type=cache,target=/home/apiuser/.cache/uv,uid=1001,gid=1001 uv sync --frozen --group dev --group admin CMD ["python", "-m", "pytest"] # ==================== # Stage 10: Production app # ==================== FROM base AS production-app ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy # Set sandbox cache permissions for apiuser RUN chown -R 1001:1001 /var/lib/tracecat/sandbox-cache && \ chmod -R 755 /var/lib/tracecat/sandbox-cache ENV PYTHONUSERBASE="/home/apiuser/.local" ENV UV_CACHE_DIR="/home/apiuser/.cache/uv" ENV PYTHONPATH="/home/apiuser/.local" ENV PATH="/home/apiuser/.local/bin:/usr/local/bin:/usr/bin:/bin" ENV TMPDIR="/home/apiuser/.cache/tmp" TEMP="/home/apiuser/.cache/tmp" TMP="/home/apiuser/.cache/tmp" RUN mkdir -p /app/.scripts && chown -R apiuser:apiuser /app # Switch to non-root user USER apiuser # Install third-party dependencies only, so this layer is reused until uv.lock or # pyproject.toml change. --frozen because uv cannot validate the lockfile # without workspace member sources; the final sync below runs with --locked. RUN --mount=type=cache,target=/home/apiuser/.cache/uv,uid=1001,gid=1001 \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --frozen --no-install-workspace --no-dev --group admin --no-editable COPY --chown=apiuser:apiuser ./tracecat /app/tracecat COPY --chown=apiuser:apiuser ./packages /app/packages COPY --from=plugin-skills --chown=apiuser:apiuser /skills/ /var/lib/tracecat/copilot-skills/ COPY --chown=apiuser:apiuser ./pyproject.toml ./uv.lock ./.python-version ./README.md ./LICENSE ./alembic.ini /app/ COPY --chown=apiuser:apiuser ./alembic /app/alembic RUN --mount=type=cache,target=/home/apiuser/.cache/uv,uid=1001,gid=1001 \ uv sync --locked --no-dev --group admin --no-editable ENV PATH="/app/.venv/bin:/home/apiuser/.local/bin:/usr/local/bin:/usr/bin:/bin" RUN ln -sf $(which uv) /home/apiuser/.local/bin/uv # ==================== # Stage 11: Production registry manifest # ==================== FROM production-app AS registry-manifest RUN /app/.venv/bin/python -m tracecat.registry.sync.prebuild # ==================== # Stage 12: Production target # ==================== FROM production-app AS production # Carry only the generated builtin registry metadata in the image so platform # registry startup can update the DB without rediscovering actions on the hot path. COPY --from=registry-manifest --chown=apiuser:apiuser /app/.registry-artifacts /app/.registry-artifacts # Verification RUN nsjail --help > /dev/null 2>&1 && echo "nsjail available" # Build metadata shown to platform admins. Declared last so a new commit only # rebuilds this metadata layer. ARG TRACECAT__BUILD_COMMIT ARG TRACECAT__BUILD_TIME ENV TRACECAT__BUILD_COMMIT=${TRACECAT__BUILD_COMMIT} \ TRACECAT__BUILD_TIME=${TRACECAT__BUILD_TIME} EXPOSE $PORT CMD ["sh", "-c", "python3 -m uvicorn tracecat.api.app:app --host $HOST --port $PORT"]