chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:29 +08:00
commit fed8b2eed7
1531 changed files with 1107494 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
services:
frontend:
build: ../frontend
environment:
- VITE_API_HOST=http://localhost:7091
- VITE_API_STREAMING=$VITE_API_STREAMING
ports:
- "5173:5173"
depends_on:
- backend
backend:
build: ../application
env_file:
- ../.env
environment:
# Override URLs to use docker service names
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1
- POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
ports:
- "7091:7091"
volumes:
- ../application/indexes:/app/application/indexes
- ../application/inputs:/app/application/inputs
- ../application/vectors:/app/application/vectors
depends_on:
redis:
condition: service_started
postgres:
condition: service_healthy
worker:
build: ../application
# `parsing` queue carries read_document/parse_document; required for its await to resolve.
command: celery -A application.app.celery worker -l INFO -Q docsgpt,parsing
env_file:
- ../.env
environment:
# Override URLs to use docker service names
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1
- API_URL=http://backend:7091
- POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
depends_on:
redis:
condition: service_started
postgres:
condition: service_healthy
# NOTE: sandboxed code execution (the `code_executor` / `artifact_generator`
# tools) is an OPT-IN feature and is intentionally NOT part of this default
# stack. To run it, layer the sandbox overlay on top of this file:
# docker compose -f docker-compose-azure.yaml \
# -f optional/docker-compose.optional.sandbox.yaml up
# See deployment/sandbox/README.md.
redis:
image: redis:6-alpine
ports:
- 6379:6379
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=docsgpt
- POSTGRES_PASSWORD=docsgpt
- POSTGRES_DB=docsgpt
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U docsgpt -d docsgpt"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_data:
+26
View File
@@ -0,0 +1,26 @@
name: docsgpt-oss
services:
redis:
image: redis:6-alpine
ports:
- 6379:6379
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=docsgpt
- POSTGRES_PASSWORD=docsgpt
- POSTGRES_DB=docsgpt
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U docsgpt -d docsgpt"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_data:
+91
View File
@@ -0,0 +1,91 @@
name: docsgpt-oss
services:
frontend:
image: arc53/docsgpt-fe:develop
environment:
- VITE_API_HOST=http://localhost:7091
- VITE_API_STREAMING=${VITE_API_STREAMING:-true}
- VITE_GOOGLE_CLIENT_ID=${VITE_GOOGLE_CLIENT_ID:-}
ports:
- "5173:5173"
depends_on:
- backend
backend:
user: root
image: arc53/docsgpt:develop
env_file:
- ../.env
environment:
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1
- CACHE_REDIS_URL=redis://redis:6379/2
- POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
ports:
- "7091:7091"
volumes:
- ../application/indexes:/app/indexes
- ../application/inputs:/app/inputs
- ../application/vectors:/app/vectors
depends_on:
redis:
condition: service_started
postgres:
condition: service_healthy
worker:
user: root
image: arc53/docsgpt:develop
# `parsing` queue carries read_document/parse_document; required for its await to resolve.
command: celery -A application.app.celery worker -l INFO -B -Q docsgpt,parsing
env_file:
- ../.env
environment:
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1
- API_URL=http://backend:7091
- CACHE_REDIS_URL=redis://redis:6379/2
- POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
volumes:
- ../application/indexes:/app/indexes
- ../application/inputs:/app/inputs
- ../application/vectors:/app/vectors
depends_on:
redis:
condition: service_started
postgres:
condition: service_healthy
# NOTE: sandboxed code execution (the `code_executor` / `artifact_generator`
# tools) is an OPT-IN feature and is intentionally NOT part of this default
# stack. To run it, layer the sandbox overlay on top of this file:
# docker compose -f docker-compose-hub.yaml \
# -f optional/docker-compose.optional.sandbox.yaml up
# See deployment/sandbox/README.md.
redis:
image: redis:6-alpine
ports:
- 6379:6379
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=docsgpt
- POSTGRES_PASSWORD=docsgpt
- POSTGRES_DB=docsgpt
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U docsgpt -d docsgpt"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_data:
+36
View File
@@ -0,0 +1,36 @@
services:
frontend:
build: ../frontend
volumes:
- ../frontend/src:/app/src
environment:
- VITE_API_HOST=http://localhost:7091
- VITE_API_STREAMING=$VITE_API_STREAMING
- VITE_EMBEDDINGS_NAME=$EMBEDDINGS_NAME
- DOCKER=true
ports:
- "5173:5173"
redis:
image: redis:6-alpine
ports:
- 6379:6379
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=docsgpt
- POSTGRES_PASSWORD=docsgpt
- POSTGRES_DB=docsgpt
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U docsgpt -d docsgpt"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_data:
+87
View File
@@ -0,0 +1,87 @@
name: docsgpt-oss
services:
frontend:
build: ../frontend
volumes:
- ../frontend/src:/app/src
environment:
- VITE_API_HOST=http://localhost:7091
- VITE_API_STREAMING=$VITE_API_STREAMING
- VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
ports:
- "5173:5173"
depends_on:
- backend
backend:
user: root
build: ../application
env_file:
- ../.env
environment:
# Override URLs to use docker service names
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1
- CACHE_REDIS_URL=redis://redis:6379/2
- POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
ports:
- "7091:7091"
volumes:
- ../application/indexes:/app/indexes
- ../application/inputs:/app/inputs
- ../application/vectors:/app/vectors
depends_on:
redis:
condition: service_started
postgres:
condition: service_healthy
worker:
user: root
build: ../application
# Consumes the default queue AND the dedicated `parsing` queue (read_document /
# parse_document). Without `parsing` here the read_document await never resolves.
# For heavy/OCR parsing run a separate worker with `-Q parsing`.
command: celery -A application.app.celery worker -l INFO -B -Q docsgpt,parsing
env_file:
- ../.env
environment:
# Override URLs to use docker service names
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1
- API_URL=http://backend:7091
- CACHE_REDIS_URL=redis://redis:6379/2
- POSTGRES_URI=postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
volumes:
- ../application/indexes:/app/indexes
- ../application/inputs:/app/inputs
- ../application/vectors:/app/vectors
depends_on:
redis:
condition: service_started
postgres:
condition: service_healthy
redis:
image: redis:6-alpine
ports:
- 6379:6379
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=docsgpt
- POSTGRES_PASSWORD=docsgpt
- POSTGRES_DB=docsgpt
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U docsgpt -d docsgpt"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_data:
@@ -0,0 +1,147 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: docsgpt-api
spec:
replicas: 1
selector:
matchLabels:
app: docsgpt-api
template:
metadata:
labels:
app: docsgpt-api
spec:
initContainers:
# Block pod start until Postgres accepts connections. The `postgres-init`
# Job is responsible for running alembic migrations; this container only
# waits for the server to be reachable.
- name: wait-for-postgres
image: postgres:16-alpine
command:
- sh
- -c
- |
until pg_isready -h postgres -p 5432 -U docsgpt -d docsgpt; do
echo "Waiting for postgres..."; sleep 2;
done
containers:
- name: docsgpt-api
image: arc53/docsgpt
ports:
- containerPort: 7091
resources:
limits:
memory: "4Gi"
cpu: "2"
requests:
memory: "2Gi"
cpu: "1"
envFrom:
- secretRef:
name: docsgpt-secrets
env:
- name: FLASK_APP
value: "application/app.py"
- name: DEPLOYMENT_TYPE
value: "cloud"
- name: POSTGRES_URI
valueFrom:
secretKeyRef:
name: docsgpt-secrets
key: POSTGRES_URI
# Disable in-app auto-bootstrap. The `postgres-init` Job under
# deployment/k8s/jobs/ owns schema creation and Alembic migrations,
# so application pods must not race with it on rollout.
- name: AUTO_MIGRATE
value: "false"
- name: AUTO_CREATE_DB
value: "false"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: docsgpt-worker
spec:
replicas: 1
selector:
matchLabels:
app: docsgpt-worker
template:
metadata:
labels:
app: docsgpt-worker
spec:
initContainers:
- name: wait-for-postgres
image: postgres:16-alpine
command:
- sh
- -c
- |
until pg_isready -h postgres -p 5432 -U docsgpt -d docsgpt; do
echo "Waiting for postgres..."; sleep 2;
done
containers:
- name: docsgpt-worker
image: arc53/docsgpt
# `parsing` queue carries read_document/parse_document; required for its await to resolve.
# For heavy/OCR parsing, run a separate deployment with `-Q parsing` (and GPU env).
command: ["celery", "-A", "application.app.celery", "worker", "-l", "INFO", "-n", "worker.%h", "-Q", "docsgpt,parsing"]
resources:
limits:
memory: "4Gi"
cpu: "2"
requests:
memory: "2Gi"
cpu: "1"
envFrom:
- secretRef:
name: docsgpt-secrets
env:
- name: API_URL
value: "http://<your-api-endpoint>"
- name: POSTGRES_URI
valueFrom:
secretKeyRef:
name: docsgpt-secrets
key: POSTGRES_URI
# Disable in-app auto-bootstrap. The `postgres-init` Job under
# deployment/k8s/jobs/ owns schema creation and Alembic migrations,
# so application pods must not race with it on rollout.
- name: AUTO_MIGRATE
value: "false"
- name: AUTO_CREATE_DB
value: "false"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: docsgpt-frontend
spec:
replicas: 1
selector:
matchLabels:
app: docsgpt-frontend
template:
metadata:
labels:
app: docsgpt-frontend
spec:
containers:
- name: docsgpt-frontend
image: arc53/docsgpt-fe
ports:
- containerPort: 5173
resources:
limits:
memory: "1Gi"
cpu: "1"
requests:
memory: "256Mi"
cpu: "100m"
env:
- name: VITE_API_HOST
value: "http://<your-api-endpoint>"
- name: VITE_API_STREAMING
value: "true"
@@ -0,0 +1,79 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi # Adjust size as needed
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_USER
value: "docsgpt"
- name: POSTGRES_PASSWORD
value: "docsgpt"
- name: POSTGRES_DB
value: "docsgpt"
- name: PGDATA
value: "/var/lib/postgresql/data/pgdata"
resources:
limits:
memory: "1Gi"
cpu: "1"
requests:
memory: "256Mi"
cpu: "100m"
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command:
- pg_isready
- -U
- docsgpt
- -d
- docsgpt
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 6
livenessProbe:
exec:
command:
- pg_isready
- -U
- docsgpt
- -d
- docsgpt
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 3
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: postgres-pvc
@@ -0,0 +1,46 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: qdrant-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: qdrant
spec:
replicas: 1
selector:
matchLabels:
app: qdrant
template:
metadata:
labels:
app: qdrant
spec:
containers:
- name: qdrant
image: qdrant/qdrant:latest
ports:
- containerPort: 6333
resources:
limits:
memory: "2Gi" # Adjust based on your needs
cpu: "1" # Adjust based on your needs
requests:
memory: "1Gi" # Adjust based on your needs
cpu: "500m" # Adjust based on your needs
volumeMounts:
- name: qdrant-data
mountPath: /qdrant/storage
volumes:
- name: qdrant-data
persistentVolumeClaim:
claimName: qdrant-pvc
@@ -0,0 +1,26 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:latest
ports:
- containerPort: 6379
resources:
limits:
memory: "1Gi"
cpu: "0.5"
requests:
memory: "512Mi"
cpu: "250m"
@@ -0,0 +1,112 @@
# docsgpt-sandbox runner (Jupyter Kernel Gateway). OPT-IN: this manifest and its
# NetworkPolicy are NOT in kustomization.yaml; apply them only when enabling
# code execution. Sessions are in-process kernels, never child containers; the
# Docker socket is NOT mounted. Network egress is constrained by the NetworkPolicy
# under deployment/k8s/network-policies/sandbox-egress-policy.yaml -- apply both.
#
# REQUIRED before enabling: create the shared gateway-token Secret and add the
# runner env to the app pods (docsgpt-api / docsgpt-worker), which the default
# docsgpt-deploy.yaml intentionally omits (sandbox is off by default):
# kubectl create secret generic docsgpt-sandbox-gateway \
# --from-literal=token="$(openssl rand -hex 32)"
# # on docsgpt-api and docsgpt-worker containers, add:
# # - name: SANDBOX_GATEWAY_URL
# # value: "http://docsgpt-sandbox:8888"
# # - name: SANDBOX_KERNEL_NAME
# # value: "docsgpt-python"
# # - name: SANDBOX_GATEWAY_AUTH_TOKEN
# # valueFrom: { secretKeyRef: { name: docsgpt-sandbox-gateway, key: token } }
# The gateway authenticates every HTTP + WebSocket request with the token, so
# kernel code cannot reach the gateway control API over loopback (enumerate/kill
# sibling kernels or bypass the session cap). The gateway fails closed if the
# token is unset.
#
# On Linux prod, schedule this onto a gVisor `runsc` RuntimeClass for kernel
# isolation (uncomment `runtimeClassName` once the node has it installed).
#
# SINGLE TRUST DOMAIN: all sessions in this pod share one container/uid and are
# isolated by working directory only (per-session cwd) — not by a kernel/OS
# boundary. This image ships an env-scrubbing kernelspec under the distinct name
# "docsgpt-python"; the app (docsgpt-api / docsgpt-worker) MUST select it via
# SANDBOX_KERNEL_NAME=docsgpt-python (already set in docsgpt-deploy.yaml) so the
# scrubber runs. The stock "python3" spec stays untouched, so it can never
# shadow the scrubbing kernel. Sibling workspaces are still readable under the
# shared uid and kernels share one address space. Do NOT inject app secrets
# (DB/LLM creds) into this pod's env; the runner needs none. For cross-tenant /
# untrusted multi-tenant workloads use a per-session VM via
# SANDBOX_BACKEND=daytona instead.
apiVersion: apps/v1
kind: Deployment
metadata:
name: docsgpt-sandbox
spec:
replicas: 1
selector:
matchLabels:
app: docsgpt-sandbox
template:
metadata:
labels:
app: docsgpt-sandbox
spec:
# runtimeClassName: gvisor
securityContext:
runAsNonRoot: true
# Numeric UID the kubelet can verify as non-root; MUST match the sandbox
# image's `USER` (deployment/sandbox/Dockerfile). Without it the pod
# fails to start with CreateContainerConfigError.
runAsUser: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: docsgpt-sandbox
image: arc53/docsgpt-sandbox
ports:
- containerPort: 8888
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
env:
# Shared gateway token; the launcher fails closed if it is unset.
# Must match SANDBOX_GATEWAY_AUTH_TOKEN on the app pods.
- name: SANDBOX_GATEWAY_AUTH_TOKEN
valueFrom:
secretKeyRef:
name: docsgpt-sandbox-gateway
key: token
- name: JUPYTER_RUNTIME_DIR
value: /tmp/jupyter-runtime
- name: JUPYTER_DATA_DIR
value: /tmp/jupyter-data
resources:
limits:
memory: "1Gi"
cpu: "1"
requests:
memory: "256Mi"
cpu: "250m"
volumeMounts:
# Per-session workspaces + Jupyter runtime files on an emptyDir;
# the root FS stays read-only everywhere else.
- name: scratch
mountPath: /tmp
volumes:
- name: scratch
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: docsgpt-sandbox
spec:
selector:
app: docsgpt-sandbox
ports:
- protocol: TCP
port: 8888
targetPort: 8888
# Cluster-internal only: the runner must never be exposed outside the cluster.
type: ClusterIP
+25
View File
@@ -0,0 +1,25 @@
apiVersion: v1
kind: Secret
metadata:
name: docsgpt-secrets
type: Opaque
# Notes:
# - POSTGRES_URI below decodes to:
# postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
# This matches the default Postgres Deployment/Service in this kustomization
# and mirrors the compose-level default (see deployment/docker-compose.yaml).
# - If you still need the MongoDB-backed vector store (VECTOR_STORE=mongodb),
# manually add a MONGO_URI key below (base64-encoded) and apply the
# opt-in manifests under deployment/k8s/optional-mongo/. Example:
# MONGO_URI: <base64 of mongodb://mongodb-service:27017/docsgpt?retryWrites=true&w=majority>
data:
LLM_PROVIDER: ZG9jc2dwdA==
INTERNAL_KEY: aW50ZXJuYWw=
CELERY_BROKER_URL: cmVkaXM6Ly9yZWRpcy1zZXJ2aWNlOjYzNzkvMA==
CELERY_RESULT_BACKEND: cmVkaXM6Ly9yZWRpcy1zZXJ2aWNlOjYzNzkvMA==
QDRANT_URL: cmVkaXM6Ly9yZWRpcy1zZXJ2aWNlOjYzNzkvMA==
QDRANT_PORT: NjM3OQ==
# postgresql://docsgpt:docsgpt@postgres:5432/docsgpt
POSTGRES_URI: cG9zdGdyZXNxbDovL2RvY3NncHQ6ZG9jc2dwdEBwb3N0Z3Jlczo1NDMyL2RvY3NncHQ=
postgres-user: ZG9jc2dwdA==
postgres-password: ZG9jc2dwdA==
@@ -0,0 +1,45 @@
# One-shot migrator: runs `python scripts/db/init_postgres.py` (alembic
# upgrade head), then exits. The backend and worker Deployments rely on
# an init container that blocks until Postgres is reachable, but this Job
# ensures the schema is migrated before application pods start serving.
#
# Re-apply after upgrades to run new migrations. Safe to re-run: idempotent.
apiVersion: batch/v1
kind: Job
metadata:
name: postgres-init
spec:
backoffLimit: 6
template:
metadata:
labels:
app: postgres-init
spec:
restartPolicy: OnFailure
initContainers:
- name: wait-for-postgres
image: postgres:16-alpine
command:
- sh
- -c
- |
until pg_isready -h postgres -p 5432 -U docsgpt -d docsgpt; do
echo "Waiting for postgres..."; sleep 2;
done
containers:
- name: postgres-init
image: arc53/docsgpt
command: ["python", "scripts/db/init_postgres.py"]
envFrom:
- secretRef:
name: docsgpt-secrets
env:
- name: FLASK_APP
value: "application/app.py"
resources:
limits:
memory: "1Gi"
cpu: "1"
requests:
memory: "256Mi"
cpu: "100m"
+20
View File
@@ -0,0 +1,20 @@
# Default DocsGPT Kubernetes stack.
#
# Uses Postgres as the user-data store (mirrors
# deployment/docker-compose.yaml). MongoDB manifests are opt-in and live
# under deployment/k8s/optional-mongo/ — they are NOT included here and
# will not be applied by `kubectl apply -k deployment/k8s/`.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- docsgpt-secrets.yaml
- deployments/postgres-deploy.yaml
- deployments/qdrant-deploy.yaml
- deployments/redis-deploy.yaml
- deployments/docsgpt-deploy.yaml
- services/postgres-service.yaml
- services/qdrant-service.yaml
- services/redis-service.yaml
- services/docsgpt-service.yaml
- jobs/postgres-init-job.yaml
@@ -0,0 +1,102 @@
# Egress / SSRF NetworkPolicy for the docsgpt-sandbox runner.
#
# The sandbox executes arbitrary LLM-authored code, so app-level URL checks
# cannot contain it -- the code opens its own sockets. The hardened container
# runs without NET_ADMIN and cannot self-apply iptables, so egress is enforced
# here at the network layer: broad public-internet egress is allowed, but all
# private / link-local / metadata ranges are DENIED. Ingress is restricted to
# the backend and worker pods only.
#
# Requires a NetworkPolicy-enforcing CNI (Calico, Cilium, etc.). The default
# kube-proxy / flannel setups do NOT enforce these rules. Apply with:
# kubectl apply -f deployment/k8s/network-policies/sandbox-egress-policy.yaml
#
# Pods this selects must carry the label `app: docsgpt-sandbox`.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: docsgpt-sandbox-egress
labels:
app: docsgpt-sandbox
spec:
podSelector:
matchLabels:
app: docsgpt-sandbox
policyTypes:
- Ingress
- Egress
ingress:
# Only the backend/worker may reach the sandbox gateway (TCP 8888).
- from:
- podSelector:
matchLabels:
app: docsgpt-api
- podSelector:
matchLabels:
app: docsgpt-worker
ports:
- protocol: TCP
port: 8888
egress:
# DNS to cluster resolvers (kube-dns). Restricted to UDP/TCP 53 so the
# broad-egress rule below does not need to whitelist the resolver IP.
# Kept for clusters WITHOUT NodeLocal DNSCache, where the resolver is a pod.
- to:
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# NodeLocal DNSCache: on clusters running it, the stub resolver is a
# link-local IP (commonly 169.254.20.10), NOT a pod, and it sits inside the
# 169.254.0.0/16 range carved out of the broad-egress rule below -- so
# without this explicit allow ALL DNS is dropped and every hostname lookup
# (pip install, public APIs) times out. NetworkPolicy rules are
# additive-allow, so this narrowly re-permits port 53 to that one IP.
# Operators whose NodeLocal DNSCache uses a different link-local IP must
# adjust this cidr.
- to:
- ipBlock:
cidr: 169.254.20.10/32 # NodeLocal DNSCache link-local resolver
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Broad public-internet egress, with every private / link-local / ULA /
# carrier-grade-NAT / documentation range carved out via `except`. The
# cloud metadata IP 169.254.169.254 is inside the 169.254.0.0/16 hole, so
# it is unreachable. SSRF to internal services is therefore blocked while
# legitimate outbound calls (package installs, public APIs) still work.
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 0.0.0.0/8 # "this network" (RFC1122)
- 10.0.0.0/8 # RFC1918 private
- 172.16.0.0/12 # RFC1918 private
- 192.168.0.0/16 # RFC1918 private
- 169.254.0.0/16 # link-local incl. 169.254.169.254 metadata
- 100.64.0.0/10 # RFC6598 carrier-grade NAT
- 127.0.0.0/8 # loopback
- 192.0.0.0/24 # IETF protocol assignments
- 192.0.2.0/24 # TEST-NET-1 (documentation)
- 198.18.0.0/15 # benchmarking
- 198.51.100.0/24 # TEST-NET-2 (documentation)
- 203.0.113.0/24 # TEST-NET-3 (documentation)
- 224.0.0.0/4 # multicast
- 240.0.0.0/4 # reserved
- 255.255.255.255/32 # limited broadcast (RFC1122)
# IPv6 public egress with private / ULA / link-local ranges carved out.
# The IPv6 metadata endpoint (fd00:ec2::254) sits inside the fc00::/7 ULA
# hole and is unreachable. Drop this block on IPv4-only clusters.
- to:
- ipBlock:
cidr: "::/0"
except:
- "::1/128" # loopback
- "fc00::/7" # unique local (ULA) incl. fd00:ec2::254
- "fe80::/10" # link-local
- "ff00::/8" # multicast
- "2001:db8::/32" # documentation
+23
View File
@@ -0,0 +1,23 @@
# Optional: MongoDB manifests
These manifests are **opt-in**. The default DocsGPT install uses Postgres
for user data (see `deployment/k8s/deployments/postgres-deploy.yaml`).
Apply the manifests in this directory only if you run DocsGPT with the
MongoDB-backed vector store (`VECTOR_STORE=mongodb`) and need an
in-cluster MongoDB, or if you are intentionally running on the legacy
MongoDB user-data store during the Postgres migration window.
Mirrors `deployment/optional/` for compose — not applied by the default
`kubectl apply -k deployment/k8s/`.
## Usage
```bash
kubectl apply -f deployment/k8s/optional-mongo/deployments/mongo-deploy.yaml
kubectl apply -f deployment/k8s/optional-mongo/services/mongo-service.yaml
```
Then extend `docsgpt-secrets.yaml` with a base64-encoded `MONGO_URI`
pointing at `mongodb://mongodb-service:27017/docsgpt?retryWrites=true&w=majority`
(or your Atlas/external URI) before re-applying the secret.
@@ -0,0 +1,46 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mongodb-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi # Adjust size as needed
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mongodb
spec:
replicas: 1
selector:
matchLabels:
app: mongodb
template:
metadata:
labels:
app: mongodb
spec:
containers:
- name: mongodb
image: mongo:latest
ports:
- containerPort: 27017
resources:
limits:
memory: "1Gi"
cpu: "0.5"
requests:
memory: "512Mi"
cpu: "250m"
volumeMounts:
- name: mongodb-data
mountPath: /data/db
volumes:
- name: mongodb-data
persistentVolumeClaim:
claimName: mongodb-pvc
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: mongodb-service
spec:
selector:
app: mongodb
ports:
- protocol: TCP
port: 27017
targetPort: 27017
type: ClusterIP
@@ -0,0 +1,25 @@
apiVersion: v1
kind: Service
metadata:
name: docsgpt-api-service
spec:
selector:
app: docsgpt-api
ports:
- protocol: TCP
port: 80
targetPort: 7091
type: LoadBalancer
---
apiVersion: v1
kind: Service
metadata:
name: docsgpt-frontend-service
spec:
selector:
app: docsgpt-frontend
ports:
- protocol: TCP
port: 80
targetPort: 5173
type: LoadBalancer
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- protocol: TCP
port: 5432
targetPort: 5432
type: ClusterIP
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: qdrant
spec:
selector:
app: qdrant
ports:
- protocol: TCP
port: 6333
targetPort: 6333
type: ClusterIP
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: redis-service
spec:
selector:
app: redis
ports:
- protocol: TCP
port: 6379
targetPort: 6379
type: ClusterIP
@@ -0,0 +1,11 @@
version: "3.8"
services:
ollama:
image: ollama/ollama
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
volumes:
ollama_data:
@@ -0,0 +1,16 @@
version: "3.8"
services:
ollama:
image: ollama/ollama
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
deploy:
resources:
reservations:
devices:
- capabilities: [gpu]
volumes:
ollama_data:
@@ -0,0 +1,121 @@
# Optional egress-firewall overlay for the docsgpt-sandbox runner (compose).
#
# Docker Compose cannot express L3 egress filtering the way a Kubernetes
# NetworkPolicy can, so SSRF containment in compose deployments is delivered by
# taking away the runner's direct internet route and forcing its outbound
# traffic through an egress-gateway sidecar that DENIES private / link-local /
# metadata ranges and ALLOWS the public internet.
#
# HOW THIS OVERLAY WORKS
# The sandbox overlay (docker-compose.optional.sandbox.yaml) puts docsgpt-sandbox
# on two networks: the internal `sandbox-net` (control plane to backend/worker)
# and `sandbox-egress` (its internet route). Layer THIS overlay on top of it to
# flip `sandbox-egress` to `internal: true`, which removes the
# runner's direct route to the internet / host / RFC1918 / metadata entirely --
# so raw sockets in arbitrary sandbox code have no route OFF the host except the
# deny-private proxy. The proxy is the ONLY container with an internet route
# (on its own `sandbox-egress-out` network) and its ACL denies private
# destinations. We flip a network's scalar property by KEY (a well-defined
# Compose merge); we do NOT try to remove an item from the service's `networks`
# LIST, which Compose cannot express (it unions lists) -- the reason a naive
# `networks: [sandbox-net]` override silently leaves the runner on its old net.
#
# WHAT THIS OVERLAY DOES NOT CONTAIN (read before enabling the sandbox):
# The runner STILL shares `sandbox-net` with `backend` and `worker` -- that is
# its control path and it cannot be removed without breaking code execution. A
# shared Docker network is bidirectional and Compose cannot sever it
# one-directionally, so arbitrary sandbox code can still open sockets to
# `backend:7091` and reach the worker. The internal flip contains raw sockets to
# the internet / host / RFC1918 / metadata, NOT to backend/worker on sandbox-net.
# (The Kubernetes NetworkPolicy DOES block this, via its RFC1918 egress
# carve-out; compose has no equivalent.)
#
# MITIGATION -- required when enabling the sandbox (do the first, ideally both):
# - Run the backend with real authentication (`AUTH_TYPE` != none / a real auth
# provider) so a reachable API rejects unauthenticated requests. Without it,
# runner->backend reach is a free control-plane bypass.
# - Add a host-firewall DROP for runner->backend/worker on sandbox-net
# (approach (1) below).
#
# Two layers below: (2) is what this overlay wires up (internet egress via the
# deny-private proxy); (1) is the host-firewall DROP that closes the
# runner->backend/worker gap. Apply both for the strongest posture.
#
# (1) Host-firewall DROP for runner->backend/worker (closes the gap above). Run
# as root on the Docker host AFTER `... up`. The project name is pinned to
# `docsgpt-oss` (see `name:` in docker-compose.yaml), so the shared control
# net is `docsgpt-oss_sandbox-net`. `docker compose ... ps -q` resolves each
# container id regardless of its generated name (there is no fixed
# `container_name`), and the `index` function is REQUIRED because a
# Go-template dotted key cannot contain hyphens -- the old
# `{{.NetworkSettings.Networks.docsgpt-oss_sandbox-egress.IPAddress}}` form
# never parsed. Both IPs are read on sandbox-net so source+dest match the
# shared path:
# CF="-f deployment/docker-compose.yaml -f deployment/optional/docker-compose.optional.sandbox.yaml -f deployment/optional/docker-compose.optional.sandbox-egress.yaml"
# NET=docsgpt-oss_sandbox-net
# SBX=$(docker inspect -f "{{(index .NetworkSettings.Networks \"$NET\").IPAddress}}" "$(docker compose $CF ps -q docsgpt-sandbox)")
# BE=$( docker inspect -f "{{(index .NetworkSettings.Networks \"$NET\").IPAddress}}" "$(docker compose $CF ps -q backend)")
# WK=$( docker inspect -f "{{(index .NetworkSettings.Networks \"$NET\").IPAddress}}" "$(docker compose $CF ps -q worker)")
# iptables -I DOCKER-USER -s "$SBX" -d "$BE" -j DROP # runner -> backend (incl. :7091)
# iptables -I DOCKER-USER -s "$SBX" -d "$WK" -j DROP # runner -> worker
# (These container-to-container DROPs require the host's
# `bridge-nf-call-iptables=1`, Docker's default, so bridged traffic
# traverses DOCKER-USER.) If a lookup is empty (renamed project, or the
# container is not up yet), resolve by hand instead of running a broken
# command: `docker compose $CF ps` to list ids, then `docker inspect
# <runner-id>` / `<backend-id>`, read the sandbox-net
# `.NetworkSettings.Networks.*.IPAddress`, and add the same DROP rules.
# Re-run after any recreate -- container IPs are not stable across `up`.
# (Internet / RFC1918 / metadata egress is already gone via the internal
# flip, so no separate SSRF drop is needed here.)
#
# (2) Egress-gateway sidecar (this overlay): the runner has NO direct internet
# route (see above); its only path out is the deny-private forward proxy.
# Point the runner's HTTP(S) client at the proxy via the env vars below.
# This does NOT stop runner->backend/worker on sandbox-net -- pair it with
# backend auth and/or approach (1).
#
# Apply on top of the base stack AND the sandbox overlay (order matters):
# docker compose -f deployment/docker-compose.yaml \
# -f deployment/optional/docker-compose.optional.sandbox.yaml \
# -f deployment/optional/docker-compose.optional.sandbox-egress.yaml up -d
#
# NOTE: because `sandbox-egress` is internal here, non-HTTP raw egress TO THE
# INTERNET / HOST / RFC1918 / METADATA is blocked at L3 regardless of what the
# code does (there is simply no route). HTTP(S) that honors the proxy env is
# filtered by the proxy ACL. This does NOT block raw egress to `backend:7091` /
# the worker on the shared `sandbox-net` (see WHAT THIS OVERLAY DOES NOT CONTAIN
# / MITIGATION above): enable backend auth and/or apply approach (1). On a
# multi-tenant or untrusted deployment, prefer the Kubernetes NetworkPolicy,
# which blocks the internal path too.
services:
# Forward proxy that denies private/link-local/metadata destinations and
# allows the public internet. Replace the image/command with your proxy of
# choice (tinyproxy, squid, mitmproxy with a deny rule, etc.); the ACL must
# DENY 10/8, 172.16/12, 192.168/16, 169.254/16, 127/8 and ALLOW the rest.
sandbox-egress-proxy:
image: ghcr.io/example/egress-deny-private:latest # replace with your proxy image
restart: unless-stopped
networks:
- sandbox-net # reachable by the runner (control-plane net)
- sandbox-egress-out # the ONLY container with an internet route
docsgpt-sandbox:
# No `networks:` override here on purpose: the sandbox overlay already
# attaches the runner to sandbox-net + sandbox-egress. Flipping sandbox-egress
# to internal (below) removes its direct internet route; its only way out is
# this proxy.
environment:
- HTTP_PROXY=http://sandbox-egress-proxy:8080
- HTTPS_PROXY=http://sandbox-egress-proxy:8080
- NO_PROXY=localhost,127.0.0.1
networks:
# Flip the runner's egress net to internal: no direct route to the internet,
# host, RFC1918, or cloud metadata. (Merged by key over the base definition.)
sandbox-egress:
internal: true
# Internet-facing route for the proxy ONLY.
sandbox-egress-out:
driver: bridge
@@ -0,0 +1,91 @@
# Optional code-execution sandbox overlay.
#
# Sandboxed code execution (the `code_executor` / `artifact_generator` tools) is
# an OPT-IN feature and is NOT part of the default stack. This overlay adds the
# `docsgpt-sandbox` runner (a Jupyter Kernel Gateway; sessions are in-process
# kernels, never child containers, and the Docker socket is NOT mounted) and
# wires the app to it.
#
# Apply on top of the base stack (order matters -- base first):
# docker compose -f deployment/docker-compose.yaml \
# -f deployment/optional/docker-compose.optional.sandbox.yaml up
#
# REQUIRED: set a shared gateway token before starting. The runner's gateway
# authenticates every HTTP + WebSocket request with it (so kernel code cannot
# reach the gateway control API over loopback -- enumerate/kill sibling kernels
# or bypass the session cap), and the app sends it as `Authorization: token`.
# Compose fails to start if it is unset. Put it in `deployment/.env` or export it:
# export SANDBOX_GATEWAY_AUTH_TOKEN=$(openssl rand -hex 32)
#
# SECURITY POSTURE (single trust domain): all sessions share this one
# container/uid and are isolated by working directory only -- sibling workspaces
# are readable and kernels share one address space. The kernelspec scrubs secrets
# from the kernel env, and the runner sits on the internal `sandbox-net` +
# `sandbox-egress` only (NOT the `default` net), so it has no Docker-DNS route to
# redis/postgres. Two residual gaps remain, exactly as documented in the egress
# overlay: (a) internet/host/RFC1918/metadata egress is open until you ALSO layer
# docker-compose.optional.sandbox-egress.yaml; (b) the runner shares sandbox-net
# with backend/worker (its control path), so on `AUTH_TYPE=none` it can reach the
# API unauthenticated -- run real auth and/or the egress overlay's host-firewall
# DROP. For untrusted multi-tenant workloads use the Daytona backend
# (SANDBOX_BACKEND=daytona, a per-session VM) or the Kubernetes manifests, whose
# NetworkPolicy blocks the internal path too.
services:
backend:
environment:
# Code-execution runner reached over HTTP + WebSocket (no docker socket).
- SANDBOX_GATEWAY_URL=http://docsgpt-sandbox:8888
# Select the runner's env-scrubbing kernelspec (distinct name; never
# shadowed by the stock "python3" spec).
- SANDBOX_KERNEL_NAME=docsgpt-python
# Shared secret the app sends as `Authorization: token <...>`. Must match
# the runner's token below; compose errors if it is unset.
- SANDBOX_GATEWAY_AUTH_TOKEN=${SANDBOX_GATEWAY_AUTH_TOKEN:?set SANDBOX_GATEWAY_AUTH_TOKEN to a shared gateway token}
networks:
- default
- sandbox-net
worker:
environment:
- SANDBOX_GATEWAY_URL=http://docsgpt-sandbox:8888
- SANDBOX_KERNEL_NAME=docsgpt-python
- SANDBOX_GATEWAY_AUTH_TOKEN=${SANDBOX_GATEWAY_AUTH_TOKEN:?set SANDBOX_GATEWAY_AUTH_TOKEN to a shared gateway token}
networks:
- default
- sandbox-net
docsgpt-sandbox:
build: ./sandbox
mem_limit: ${SANDBOX_MEMORY:-1g}
cpus: ${SANDBOX_CPUS:-1.0}
pids_limit: 256
read_only: true
environment:
# The gateway REQUIRES this and fails closed if unset (see gateway-launch.sh).
# Do NOT add `env_file: ../.env` here -- the runner needs no other app secret.
- SANDBOX_GATEWAY_AUTH_TOKEN=${SANDBOX_GATEWAY_AUTH_TOKEN:?set SANDBOX_GATEWAY_AUTH_TOKEN to a shared gateway token}
# Keep Jupyter's runtime/connection files on the writable tmpfs.
- JUPYTER_RUNTIME_DIR=/tmp/jupyter-runtime
- JUPYTER_DATA_DIR=/tmp/jupyter-data
tmpfs:
# Per-session workspaces (/tmp/docsgpt-sandbox/<session_id>) and Jupyter
# runtime files live on tmpfs; the root FS is read-only everywhere else.
- /tmp
networks:
# Reachable by backend/worker over the internal sandbox-net; internet egress
# (runtime pip install, etc.) via the dedicated sandbox-egress net. NOT on
# `default`: the egress overlay cuts internet by flipping sandbox-egress to
# internal, without severing the control path.
- sandbox-net
- sandbox-egress
networks:
# Control plane between backend/worker and the runner. internal:true => no
# internet route on this net; the runner egresses via sandbox-egress.
sandbox-net:
internal: true
# The runner's outbound internet route. Internet-facing by default so runtime
# pip install works; docker-compose.optional.sandbox-egress.yaml flips it to
# internal and forces egress through a deny-private proxy for SSRF containment.
sandbox-egress: {}
+75
View File
@@ -0,0 +1,75 @@
# docsgpt-sandbox: the always-on code-execution runner.
#
# One container runs a Jupyter Kernel Gateway; each sandbox session is an
# in-process kernel (a child process), NOT a child container. This image does
# NOT mount the Docker socket and never spawns containers — that avoids the
# host-root risk of Docker-in-Docker and is the core security win.
#
# The app (backend/worker) is the CLIENT and reaches this service over
# HTTP + WebSocket via SANDBOX_GATEWAY_URL.
#
# Pre-baked doc libs are all permissive (MIT/BSD/Apache) for speed/reliability;
# runtime `pip install` still works because egress is open. PyMuPDF and any
# other AGPL lib are intentionally excluded; Docling is deferred to its own slice.
#
# HARDENING (separate slice — NOT done here): run under the gVisor `runsc`
# runtime, add network-layer SSRF blocks (drop RFC1918 / link-local /
# 169.254.169.254), seccomp profile, read-only root FS + quota'd scratch dir,
# and cgroup CPU/mem/PID caps wired from SANDBOX_MEMORY / SANDBOX_CPUS.
FROM python:3.12-slim
# Non-root user for the runner (untrusted code runs as this UID).
RUN useradd --create-home --uid 10001 sandbox
# Exact pins for reproducible builds and no license drift (all MIT/BSD/Apache).
RUN pip install --no-cache-dir \
jupyter-kernel-gateway==3.0.1 \
ipykernel==6.29.5 \
python-pptx==1.0.2 \
python-docx==1.1.2 \
openpyxl==3.1.5 \
reportlab==4.2.5 \
pandas==2.2.3 \
matplotlib==3.9.2
# Docling (MIT) — OFF by default because it pulls torch + models and makes the
# image multi-GB. NOTE: document parsing now runs on the Celery `parsing` worker
# (see read_document / parse_document_worker), NOT in this sandbox, so this
# INSTALL_DOCLING build-arg path is effectively unused for the read_document flow;
# it remains only for sandbox code that opts into Docling explicitly. The build
# mechanics are kept for that case. Docling is MIT and uses its own PDF backend;
# PyMuPDF (AGPL) is NOT installed here. The base image stays docling-free.
ARG INSTALL_DOCLING=false
RUN if [ "$INSTALL_DOCLING" = "true" ]; then \
pip install --no-cache-dir docling==2.8.3; \
fi
# Env-scrubbing kernel launcher + custom kernelspec. The launcher re-execs
# ipykernel under a minimal allowlisted env (env -i) so NO secret in the
# gateway's environment (*_API_KEY, *_TOKEN, POSTGRES_URI, the gateway auth
# token, ...) ever reaches kernel code. The kernelspec ships under a DISTINCT
# name ("docsgpt-python"), so the app selects it with SANDBOX_KERNEL_NAME and it
# is never shadowed by the stock ipykernel "python3" spec regardless of the
# python prefix. The stock "python3" spec is left untouched (no overwrite, no
# kernelspec-name precedence to rely on). SECURITY: never give this image
# `env_file: ../.env` -- the scrubber blocks exfil from the kernel, but the
# runner image itself should stay free of app secrets it has no use for.
COPY kernel-launch.sh /opt/docsgpt/kernel-launch.sh
RUN chmod 0555 /opt/docsgpt/kernel-launch.sh
COPY gateway-launch.sh /opt/docsgpt/gateway-launch.sh
RUN chmod 0555 /opt/docsgpt/gateway-launch.sh
COPY kernels/docsgpt-python/kernel.json /usr/local/share/jupyter/kernels/docsgpt-python/kernel.json
# Numeric UID (not the name) so a kubelet with `runAsNonRoot: true` can verify
# the user is non-root without resolving /etc/passwd. This uid MUST match
# `runAsUser` in deployment/k8s/deployments/sandbox-deploy.yaml.
USER 10001
WORKDIR /home/sandbox
EXPOSE 8888
# The launcher REQUIRES SANDBOX_GATEWAY_AUTH_TOKEN and fails closed if it is
# unset: the gateway control API is reachable from kernel code over loopback, so
# it must never run unauthenticated (see gateway-launch.sh). The token is shared
# with the app and is scrubbed from the kernel env by kernel-launch.sh.
CMD ["/opt/docsgpt/gateway-launch.sh"]
+284
View File
@@ -0,0 +1,284 @@
# docsgpt-sandbox runner
Opt-in Jupyter Kernel Gateway that executes sandboxed LLM code. The DocsGPT
backend/worker is the **client** and connects over HTTP + WebSocket via
`SANDBOX_GATEWAY_URL`. Each session is an **in-process kernel** (child process),
never a child container; the Docker socket is **not** mounted.
## Enabling code execution (opt-in)
The runner is **opt-in**. Neither `code_executor` nor `artifact_generator` is a
default chat tool (both were removed from `DEFAULT_CHAT_TOOLS`), and the runner
is **not** part of the base compose stack — a plain `docker compose up` does
**not** start `docsgpt-sandbox`. Enable it by layering the sandbox overlay and
setting a shared gateway token:
```bash
export SANDBOX_GATEWAY_AUTH_TOKEN=$(openssl rand -hex 32)
docker compose \
-f deployment/docker-compose.yaml \
-f deployment/optional/docker-compose.optional.sandbox.yaml up
```
The token is **required** — the gateway fails closed if it is unset (see
*Gateway authentication* below). Add the egress-firewall overlay for SSRF
containment (see *Network egress / SSRF*):
```bash
docker compose \
-f deployment/docker-compose.yaml \
-f deployment/optional/docker-compose.optional.sandbox.yaml \
-f deployment/optional/docker-compose.optional.sandbox-egress.yaml up
```
Then enable `code_executor` / `artifact_generator` **per-agent** in the agent
tool picker. Agents without them never call the runner, and the backend/worker
degrade gracefully when the runner is absent. The `-hub` and `-azure` compose
variants take the same sandbox overlay.
## Isolation model
Read this before pointing untrusted or multi-tenant workloads at the runner.
A single Jupyter runner is **one trust domain**. Every session is an in-process
kernel under **one shared uid (10001)** in **one container**; sessions are
isolated by **working directory only** — each session's code runs with its cwd
set to its own `/tmp/docsgpt-sandbox/<session_id>` directory. That is a
convenience boundary, not a security boundary between sessions.
What this slice does close:
- **Env-secret exfil is closed.** The custom kernelspec
(`kernels/docsgpt-python/kernel.json``/opt/docsgpt/kernel-launch.sh`)
re-execs ipykernel under a minimal allowlisted env (`env -i` keeping only
`PATH`, `HOME`, `LANG`, `JUPYTER_RUNTIME_DIR`, `JUPYTER_DATA_DIR`). The image
installs this spec under the **distinct name `docsgpt-python`** and the app
selects it via `SANDBOX_KERNEL_NAME=docsgpt-python`; because the name is
distinct, it is **never shadowed** by the stock ipykernel `python3` spec
(kernelspec name resolution prefers `sys.prefix/share` over
`/usr/local/share`, so reusing `python3` would silently fall back to the
unscrubbed stock spec on a different python prefix). The stock `python3` spec
is left untouched. So even though the gateway process inherits the operator's
full environment, **no `*_API_KEY` / `*_TOKEN` / `POSTGRES_URI` / gateway auth
token reaches kernel code** via `os.environ`, regardless of how the gateway is
launched. Loopback ZMQ reachability is preserved because `{connection_file}`
is forwarded untouched.
- **Per-session workspace perms.** The workspace root and each session dir are
created `0700` (defense-in-depth). Under one shared uid this does **not** stop
a sibling session from reading another's files — it only narrows exposure to
other uids on the box.
Residual gaps (treat all sessions in one runner as mutually trusting):
- **Sibling-workspace reads.** All kernels run as the same uid, so one session's
code can read another session's files (and `/tmp`) despite `0700`. Distinct
uids / per-session VMs are required to close this.
- **In-memory / cross-kernel.** Kernels are child processes of one gateway under
one uid; OS-level process isolation is the only boundary, and it is not a
sandbox boundary against a determined escape. No gVisor in the base posture.
(The gateway's HTTP/WebSocket control API is reachable from kernel code over
loopback, but it is **authenticated** — see *Gateway authentication* — and the
token is scrubbed from the kernel env, so kernel code cannot drive it to
enumerate/kill sibling kernels or spawn kernels past the session cap.)
- **Egress.** Outbound is broad by design (so code can `pip install` / call
public APIs). Private/link-local/metadata ranges are blocked **only** by the
network layer — the k8s NetworkPolicy or a host/cloud firewall (see *Network
egress / SSRF* below), never by the runner itself.
For real per-tenant isolation (cross-tenant or untrusted code), use the
**Daytona backend** (`SANDBOX_BACKEND=daytona`), which gives each session its
own VM. To harden the self-hosted Jupyter runner as a whole (host protection +
egress), layer the **gVisor `runsc` runtime**, the **NetworkPolicy**, and a
**host firewall** as documented below — those protect the host and constrain
egress; they do **not** create a boundary between sessions inside one runner.
## Run standalone for dev
Build and run the runner on its own, then point the app at it:
```bash
docker build -t docsgpt-sandbox deployment/sandbox
docker run --rm -p 8888:8888 -e SANDBOX_GATEWAY_AUTH_TOKEN=devtoken docsgpt-sandbox
# in the app's .env: SANDBOX_GATEWAY_URL=http://localhost:8888
# SANDBOX_GATEWAY_AUTH_TOKEN=devtoken
```
The token is required — the image's entrypoint refuses to start without it (see
*Gateway authentication*). Without Docker (matches the test harness) you can run
the gateway directly from a venv that has `jupyter-kernel-gateway` installed; set
a matching `--KernelGatewayApp.auth_token`:
```bash
jupyter kernelgateway --KernelGatewayApp.ip=0.0.0.0 --KernelGatewayApp.port=8888 \
--KernelGatewayApp.auth_token=devtoken \
--ZMQChannelsWebsocketConnection.limit_rate=False
```
`--ZMQChannelsWebsocketConnection.limit_rate=False` raises the iopub data-rate
limit so large `get_file` base64 payloads aren't silently truncated. (On older
gateways the trait may live elsewhere; the client's `get_file` integrity check
catches any truncation regardless.)
A bare-venv gateway uses the **stock** `python3` kernelspec, which inherits the
gateway's full env (no secret scrubbing). The default `SANDBOX_KERNEL_NAME` is
`python3`, so plain venv dev gets no scrubbing — acceptable for single-trust
dev. The Docker image instead ships the env-scrubbing spec under the distinct
name `docsgpt-python` (see *Isolation model*) and the runner stack sets
`SANDBOX_KERNEL_NAME=docsgpt-python`. To get the scrubbing behavior in a venv,
copy `kernels/docsgpt-python/kernel.json` (pointing `argv` at a local copy of
`kernel-launch.sh`) into a Jupyter data dir on the kernelspec search path and
set `SANDBOX_KERNEL_NAME=docsgpt-python` before launching.
## Gateway authentication
The gateway **requires** an auth token and **fails closed** if it is unset — the
image's entrypoint (`gateway-launch.sh`) refuses to start an unauthenticated
gateway. This matters even on an internal-only network: the gateway and every
session kernel share one container, so kernel code can reach the gateway's
control API over **loopback** (`http://localhost:8888`). Without auth, that
control API would let kernel code enumerate/attach/kill sibling sessions'
kernels and spawn kernels without bound (bypassing the app-side session cap).
Set the same token on the runner and the app via `SANDBOX_GATEWAY_AUTH_TOKEN`
(the app sends it as `Authorization: token <...>`; the runner's gateway
validates it on every HTTP + WebSocket request). Kernel code cannot read it: the
kernelspec launcher scrubs it from the kernel env (see *Isolation model*), so it
is present for the gateway process only. The image also does **not** set
`--KernelGatewayApp.allow_origin=*`.
## In docker-compose
The `docsgpt-sandbox` service lives in the opt-in overlay
`deployment/optional/docker-compose.optional.sandbox.yaml` (layered on the base
stack; see *Enabling code execution (opt-in)*) on an internal-only network with
no published host port. The overlay puts the backend and worker on `sandbox-net`
to reach the runner at `http://docsgpt-sandbox:8888`, and sets
`SANDBOX_KERNEL_NAME=docsgpt-python` on them (the runner only ships the
kernelspec; the app chooses it) plus the shared `SANDBOX_GATEWAY_AUTH_TOKEN`. In
k8s these are added to the `docsgpt-api` and `docsgpt-worker` deployments when
enabling the opt-in `sandbox-deploy.yaml` (the default `docsgpt-deploy.yaml`
omits them); see that manifest's header for the exact env and the token Secret.
## Artifact rendering on Daytona (snapshot)
The `artifact` tool renders `presentation` / `document` / `spreadsheet` / `pdf`
specs by running a fixed renderer **inside the sandbox** that imports
`python-pptx`, `python-docx`, `openpyxl`, and `reportlab` (HTML/markdown need no
library). The self-hosted Jupyter runner inherits these from the backend venv,
but Daytona's default snapshot is a plain Python image — so under
`SANDBOX_BACKEND=daytona` those renders fail with `render failed: ExecutionError`
(a `ModuleNotFoundError` raised inside the sandbox). HTML/markdown still work.
Bake the libraries into a Daytona snapshot once, then point `DAYTONA_SNAPSHOT`
at it:
```bash
# Reads DAYTONA_API_KEY / DAYTONA_API_URL / DAYTONA_TARGET from .env:
python scripts/build_daytona_snapshot.py # builds "docsgpt-artifacts-py312"
# then in .env:
# DAYTONA_SNAPSHOT=docsgpt-artifacts-py312
```
The snapshot lives in **your** Daytona account, so each deployment builds its
own — the script is idempotent and skips if the name already exists. Keep the
pins in `scripts/build_daytona_snapshot.py` in sync with the backend venv so the
Daytona render output matches the Jupyter-backend output.
## Document reading (parsing worker — not the sandbox)
Document reading no longer runs in this sandbox. The `read_document` tool and the
workflow native-file extract branch enqueue a `parse_document` Celery task that
parses the document **in the backend** (Docling, already in
`application/requirements.txt`) and awaits the result. The task is routed to a
dedicated **`parsing` queue** (`settings.DOCUMENT_PARSE_QUEUE`, default
`"parsing"`) so a parse enqueued from inside a Celery worker (headless/scheduled
agent) is served by a separate worker and never self-deadlocks the awaiting one.
Run a dedicated parsing worker that consumes the `parsing` queue:
```bash
celery -A application.app.celery worker -Q parsing -l INFO
```
It can be GPU-enabled with its own env (`DOCLING_OCR_ENABLED=true` plus GPU
libraries) so OCR-heavy parsing runs on a separate, optionally larger pool.
**Dev / single-worker setups:** without a dedicated parsing worker the default
worker must also consume `parsing`, or the tool's await never resolves:
```bash
celery -A application.app.celery worker -Q docsgpt,parsing -l INFO
```
Tuning settings: `DOCUMENT_PARSE_TIMEOUT` (seconds the tool awaits before
degrading to an error), `DOCUMENT_PARSE_MAX_BYTES` (per-document byte cap; 0
reuses `SANDBOX_MAX_INPUT_BYTES`).
## Network egress / SSRF
The runner allows **broad outbound egress** (so sandboxed code can `pip install`
and call public APIs) but private, link-local, and cloud-metadata ranges **MUST
be blocked at the network layer**. This is not optional: the sandbox executes
arbitrary LLM-authored code, which opens its own sockets — app-level URL checks
(the `mcp_tool.py` approach) cannot contain it. Without a network-layer block,
sandbox code can reach `169.254.169.254` (cloud instance metadata / credentials)
and internal services on the private network.
The hardened container runs **without `NET_ADMIN`**, so it cannot self-apply
`iptables`. Enforcement therefore lives in deployment config:
- **Kubernetes** — apply
[`deployment/k8s/network-policies/sandbox-egress-policy.yaml`](../k8s/network-policies/sandbox-egress-policy.yaml).
It allows `0.0.0.0/0` egress with `except` carve-outs for RFC1918
(`10/8`, `172.16/12`, `192.168/16`), link-local (`169.254/16`, which contains
`169.254.169.254`), loopback, CGNAT, documentation/test ranges, and the IPv6
ULA/link-local equivalents — and restricts ingress to the API/worker pods on
TCP 8888. It requires a policy-enforcing CNI (Calico, Cilium, …); plain
flannel/kube-proxy will silently not enforce it. The matching sandbox pod is
[`deployment/k8s/deployments/sandbox-deploy.yaml`](../k8s/deployments/sandbox-deploy.yaml)
(label `app: docsgpt-sandbox`).
```bash
kubectl apply -f deployment/k8s/deployments/sandbox-deploy.yaml
kubectl apply -f deployment/k8s/network-policies/sandbox-egress-policy.yaml
```
- **docker-compose** — compose cannot express L3 egress filtering natively. The
sandbox overlay reaches the runner over an `internal: true` control network
(`sandbox-net`, no host port) and gives it internet egress on a dedicated
`sandbox-egress` bridge — but that bridge does not by itself block the metadata
IP or RFC1918. Apply
[`deployment/optional/docker-compose.optional.sandbox-egress.yaml`](../optional/docker-compose.optional.sandbox-egress.yaml),
which flips `sandbox-egress` to `internal: true` (removing the runner's direct
internet/RFC1918/metadata route entirely) and forces egress through a
deny-private **egress-gateway proxy** sidecar. That `internal` flip is what
contains **raw sockets to the internet/host/RFC1918/metadata** (a forward proxy
only filters code that honors `HTTP(S)_PROXY`).
**What compose canNOT contain:** the runner stays on `sandbox-net` with the
backend and worker — that is its control path, and a shared Docker network is
bidirectional, so Compose cannot sever it one-directionally. Sandbox code can
therefore still open sockets to `backend:7091` and the worker. This is a real
gap the Kubernetes NetworkPolicy closes (via its RFC1918 egress carve-out) but
compose cannot. **Mitigate it** when enabling the sandbox: run the backend with
real authentication (`AUTH_TYPE` != none / a real auth provider) so a reachable
API rejects unauthenticated requests — **required** — and/or add a host-firewall
`DROP` for runner→backend/worker on `sandbox-net` (see approach (1) in the
overlay file's header comment). The runner is not on the `default` network, so
it has no Docker-DNS route to redis/postgres; if the broker/DB publish host
ports on a cloud VM, also apply the egress overlay (its `internal` flip removes
the runner's route to the host gateway / RFC1918) or bind those ports to
`127.0.0.1`.
## Other hardening (deployment-level)
The gVisor `runsc` runtime (kernel isolation for untrusted code), seccomp
profile, read-only root FS, non-root, and cgroup CPU/mem/PID caps (wired from
`SANDBOX_MEMORY` / `SANDBOX_CPUS`) are deployment-level concerns. The compose
service in `deployment/optional/docker-compose.optional.sandbox.yaml` already
sets `read_only`, `mem_limit`, `cpus`, and `pids_limit`; the k8s
`sandbox-deploy.yaml` sets the
equivalent `securityContext` + resource limits and has a commented
`runtimeClassName: gvisor` to enable on nodes with the `runsc` RuntimeClass
installed. These complement — they do not replace — the network egress policy
above.
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
# Entrypoint for the docsgpt-sandbox Jupyter Kernel Gateway.
#
# The gateway and every session kernel share this one container, so LLM-authored
# kernel code can reach the gateway's control API over loopback
# (http://localhost:8888). WITHOUT authentication that means kernel code could
# enumerate/attach/kill sibling sessions' kernels and spawn kernels without bound
# (bypassing the app-side session cap). We therefore REQUIRE an auth token and
# fail closed if it is missing -- an unauthenticated gateway must never start.
#
# The token is shared with the app via SANDBOX_GATEWAY_AUTH_TOKEN (the app sends
# it as `Authorization: token <...>`). kernel-launch.sh re-execs ipykernel under a
# scrubbed `env -i` allowlist that excludes the token, so it is NOT in kernel code's
# os.environ. NOTE: this is not a hard secret boundary -- the gateway and kernels
# share one container and uid, so kernel code can still read the gateway's env via
# /proc/<gateway_pid>/environ. The token limits the blast radius (an outside caller
# cannot drive the gateway), but the Jupyter runner is a single trust domain: for
# real isolation against untrusted code use the Daytona backend or run under gVisor
# (see deployment/sandbox/README.md).
set -eu
TOKEN="${SANDBOX_GATEWAY_AUTH_TOKEN:-}"
if [ -z "$TOKEN" ]; then
echo "docsgpt-sandbox: refusing to start an unauthenticated gateway." >&2
echo "Set SANDBOX_GATEWAY_AUTH_TOKEN (same value on the app and this container)." >&2
exit 1
fi
# ip=0.0.0.0 so the backend/worker can reach it over the internal sandbox network.
# auth_token gates every HTTP + WebSocket request, including loopback ones from
# kernel code. limit_rate=False raises the iopub data-rate cap so large get_file
# base64 payloads are not truncated (the get_file integrity check still guards
# truncation if this is ever off).
exec jupyter kernelgateway \
--KernelGatewayApp.ip=0.0.0.0 \
--KernelGatewayApp.port=8888 \
--KernelGatewayApp.auth_token="$TOKEN" \
--ZMQChannelsWebsocketConnection.limit_rate=False
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Env-scrubbing launcher for the docsgpt-sandbox ipykernel.
#
# The gateway process inherits the operator's full environment, which can carry
# secrets (*_API_KEY, *_TOKEN, POSTGRES_URI, the gateway auth token, ...). Stock
# kernels inherit that env verbatim, so LLM-authored code could read it via
# os.environ. This wrapper re-execs ipykernel under a MINIMAL allowlisted env so
# NO secret reaches kernel code, regardless of how the gateway was launched.
#
# Only what ipykernel needs is kept: PATH (find python), HOME (~/.ipython etc),
# LANG (encoding), and the Jupyter runtime/data dirs (writable tmpfs paths). The
# {connection_file} the gateway passes is forwarded via "$@" so loopback ZMQ
# reachability is preserved -- do NOT drop or rewrite those args.
exec env -i \
PATH="${PATH}" \
HOME="${HOME}" \
LANG="${LANG}" \
JUPYTER_RUNTIME_DIR="${JUPYTER_RUNTIME_DIR}" \
JUPYTER_DATA_DIR="${JUPYTER_DATA_DIR}" \
python -m ipykernel_launcher "$@"
@@ -0,0 +1,12 @@
{
"argv": [
"/opt/docsgpt/kernel-launch.sh",
"-f",
"{connection_file}"
],
"display_name": "Python 3 (docsgpt-sandbox, scrubbed env)",
"language": "python",
"metadata": {
"debugger": true
}
}