Component boundaries
In a k3s installation the app runs as UID 1000 with no Docker socket and no Kubernetes credentials. Only the private runtime broker gets a namespaced service-account token, and it creates Pods and Services from a fixed template and a trusted catalog. The API doesn't accept an arbitrary Pod template or host path. The ready-made image recommended for universities works differently: the app holds the Docker socket, which is root on the VM; see Your own server (university).
Each room gets its own Jupyter Pod, token and workspace subPath. If the runtime, the image or a policy is unavailable, nothing starts: there is no fallback to a shared server kernel.
Pod restrictions
A room Pod runs as UID/GID 1000 with no extra capabilities and no privilege escalation, with the RuntimeDefault seccomp profile and a read-only root filesystem. The service-account token is not mounted. Writable areas: the room directory, /tmp, /home/runner and /dev/shm.
Pod Security enforces the restricted profile. The broker's RBAC is limited to Pod and Service operations in the namespace; the only way it can change a running Pod is the pods/resize subresource, which it uses to change a room's memory. But RBAC can't restrict every field of the Pod being created, so the broker's code, its templates and the catalog are part of the trusted base.
Network: check the actual result
NetworkPolicy allows inbound connections to Jupyter only from the app and the broker. By default, rooms may send traffic out only to the DNS Pod in kube-system over TCP/UDP 53. To download external data you need a separately designed outbound proxy or profile.
If you need isolation from host services, add a CNI with host policy or a tested firewall policy that allows only the broker to reach the Kubernetes API. Before you let untrusted users in, run a real room-to-node connection test. Having a manifest is not proof.
Files and quotas
Linux production accesses files through trusted file descriptors, O_NOFOLLOW and /proc/self/fd. Symbolic links can't escape the room directory. If the protected mechanism is unavailable, production refuses to run.
An old workspace must not contain hard links between rooms. When migrating, copy regular files into separate directories and check owners and links. Path checks don't turn two names of an existing shared inode into independent data.
PVC size is scheduling metadata, not a filesystem quota. MAX_UPLOAD_MB and MAX_SESSION_MB limit what the app does and don't stop Python from writing whatever it wants. Use a separate partition and monitor free space.
Competitions: what is closed and what is not
A competition submission runs in a throwaway container: one per submission, no network, data read-only, under the same hardening a room gets (uid 1000, no capabilities, no-new-privileges, a process ceiling, a read-only root, memory and CPU caps). The container is destroyed once the submission is done.
The hidden answers (solution.csv) and your metric code live in the competition's directory under DATA_DIR and never in a class workspace. They do not enter the container that runs an entrant's notebook at all: the metric is computed by a second container, which by then holds neither the entrant's data nor their code. The split seed leaves through no /api/k door. The hidden test is never handed to an entrant; only the competition's teachers download it — in its materials or by its file's link — and every download is recorded in the Audit log. The answers file is not among the materials: no link takes its contents off the server.
Submission machines
A submission machine runs the notebooks of this server's competition submissions on its own Docker, in the same throwaway sandbox with no network as the server. It gets the notebooks of the submissions it runs, the competition's open data and the ready package sets of its participants. Only a trusted machine gets the hidden test, and trust is off by default. The hidden answers, the metric code, scores, names, emails, sign-in keys and the database never go to a machine: the server still runs the metric. Remember that a participant's notebook may hold anything they wrote in it.
The code and the secret. When joining, a machine presents the owner's code: 128 bits, for the number of machines set and a day at most; the server keeps only its fingerprint, and only the owner mints codes. The owner can cancel a code still valid before it expires (Cancel under Join codes), and Disconnect all cancels every code still valid too: otherwise whoever still holds a code could join a new machine with it. In return the machine gets a secret of its own, 256 bits; the server keeps its sha256 and compares it in constant time. The secret travels only in the Authorization header and only to /api/runners/v1: no other door of the server — the panel, the competition page, rooms, WebSockets — accepts it, and the machines' door accepts no staff cookie, participant key or room token. The relay token never reaches a machine: it opens no address and calls the server by itself.
Disconnecting. Disconnect in the panel takes effect at the machine's next call: the server answers 401, its runs go back to the queue at no cost to participants, late results are refused, and the agent purges the hidden test and stops. Taking trust away likewise sends its runs with the hidden test back to the queue, cuts a download of the test short, and purges the test from the machine. Minting a code, in the panel or with colloq-host runner-code, cancelling codes, connecting a machine, trusting it or taking trust away, and disconnecting it go into the Audit log. In the log, a code minted with the command, and a machine that joins with it, show Server console where a person would stand; Join codes shows such a code as minted on the server's console.
What comes back the server takes as participant data: only two files with known names, size limits after decompression, a sha256 check, staging outside the attempt directories, and a check that the result belongs to that very attempt. The texts a participant reads, the brief ones under a hidden test included, the server writes itself.
Class machines
A machine opened to classes sees everything its classes have: every cell and the output of the class notebooks and personal notebooks, a copy of the class folder, the terminal and the kernels' memory — a key or a password pasted into a cell included. What submissions keep on the server alone has no counterpart here: a class on a machine is there whole. So only the owner opens classes, machine by machine, and they are off by default. A machine with classes never gets the hidden test: the flag does not go together with Trusted, and a machine still holding class containers is given no run with the hidden test.
What the machine does not see. The link-signing key (SESSION_SECRET), the database, other classes, staff cookies, the AI model's key and the relay token. The Jupyter token of a class container on the machine is derived from the signing key, one for each class, machine and generation, so a machine the kernel moved away from cannot open it in its new place.
The sandbox is the server's. The agent starts class kernels with the very Docker arguments the server uses (uid 1000, no capabilities, no-new-privileges, a process ceiling, no IPv6), and fences their network with the same rules, in iptables chains of its own (COLLOQ-MR<label>-*) and the network colloq-mrooms-<label>. The server's network mode is the floor: a machine may be stricter (COLLOQ_ROOM_NETWORK=none, ranges of its own blocked) but never looser — open has no effect on a machine, and the server's blocked ranges (KERNEL_BLOCKED_CIDRS) travel to the machine with every kernel. The rules are checked every minute; after three failures in a row the machine stops every class container by itself, and the classes move. Rootless Docker and Docker without iptables get no classes. Before a machine takes a class, a throwaway container under a class's hardening checks the sandbox from the inside: not root, no capabilities and no way to gain privileges, no Docker socket and none of the agent's files in sight, and no way to the cloud metadata, the machine itself, its local network, IPv6 or another class's port. The kernels' Jupyter listens on the machine's 127.0.0.1 only, and of the agent's directory only the class's own folder is mounted into its containers — and into the council's container three things of its own besides: the notebook's snapshots read-only (/colloq/epochs), the attempts' folders (/colloq/scratch), and, read-only too, the gate that lets a worker's kernel run an attempt only once the attempt's entry has held (/colloq/gate and /etc/ipython/ipython_kernel_config.py). The attempts' folder belongs to root, mode 1733: a worker makes its attempt's folder there and enters it by name, but sees none of its neighbours' and cannot change the folder's mode. A throwaway container of the agent's own image makes it so before every start of the council's container: the agent has no root of its own, and its uid is the kernels' own.
The tunnel and the machine's secret. The machine's same 256-bit secret opens its tunnel too, the WebSocket /api/runners/v1/tunnel — again only in the Authorization header, and only for a machine opened to classes. The tunnel carries only the bytes of connections the server opens, to the kernel ports the agent recorded itself: no command rides it, and nothing else on the machine can be reached through it. With its secret a machine reaches only its own classes, and only at their current generation: the server hands it a file of the class folder only when it put that file in a push to this machine, and refuses the changes of a generation that moved away (a late file gets 409). What the machine says its kernels changed, the server applies only over the version both sides last agreed on: a file changed here since keeps the server's version, and the machine's lands beside it as a conflict copy. A small file comes in the report itself; the bytes of a larger one the server takes only for a pull it asked for. Whatever the machine sends, the server takes as participant data: paths and types are checked again, no link is ever created, checksums and sizes are verified, the class's caps hold, and notebook files are refused.
Disconnecting. Disconnect and Stop taking classes move the machine's classes first, then close its tunnel, and the agent removes the class containers and folder copies. Turning classes on and off, Resume classes and Move classes go into the Audit log, and tunnel replacements with both addresses, moves and the quarantine into the events on the machine's page; frequent replacements from different addresses raise a banner (see link takeovers).
Access, links and secrets
A room link gives access to the class; a teacher's sign-in link gives access to the panel. The setup token lets an owner regain control. Keep secrets and backups private; full access to the server's disk means access to its data and keys.
Separate answers in a Council don't give each student a sandbox of their own: in the shared kernel the attempts run one after another in the notebook's kernel, and in the pool on a machine the workers are processes of one container under one uid. The “Dangerous commands” rule refuses the lines that end a kernel out of curiosity (exit(), os._exit(), %reset, !kill -9 -1, rm -rf of the class folder), but it is a speed bump, not a sandbox: ctypes, a fork bomb and the room's terminal all walk past it. Action limits and browser bans don't verify identity. The system doesn't promise automatic grading, SSO or protected exam execution.
Deployment limits
Containers share the VM's Linux kernel. This is not VM-level isolation and doesn't protect against kernel vulnerabilities. One VM, one k3s node and local PVs give no HA. If the machine is lost, you need an external backup; updates interrupt work and lose Python memory.
A starting point for resources, scenarios and a formula for memory are under Machine and sizing. Those numbers are derived from the limits in the code, not from a load test, so check them against your own classes: the load depends on the computation, output, files, active rooms and network.
A class on your own computer
A local run — colloq start or make dev — listens on 127.0.0.1 only; the class is visible from outside only after an explicit colloq host. Room kernels are separate Docker containers, and the server manages them through the Docker daemon, which amounts to root on that machine. On macOS every path segment is opened with O_NOFOLLOW, but without the Linux /proc/self/fd mechanism a race window remains between the check and the operation.
Every room container gets the same hardened profile, however the class was started (colloq start, make dev, make run, make up, the vast.ai image): UID 1000, no Linux capabilities, no-new-privileges, Docker's default seccomp profile, a process limit (KERNEL_PIDS, 512 by default) and no IPv6. Students' personal notebooks run in the class's second container — the same profile, but with its own process ceiling (KERNEL_OWN_PIDS, 2048 by default), its own Jupyter token and no access to the class's GPU. The room reaches the internet — pip install, datasets, APIs — but not local addresses: 10/8, 172.16/12, 192.168/16, 100.64/10, link-local 169.254/16 with cloud metadata, loopback, multicast, the computer itself and the other rooms. A connection there is refused at once. Under make up and in the vast.ai image rooms share a Docker network with the server, and traffic inside that network is filtered only when the host passes bridged traffic through iptables, as Docker normally arranges; otherwise the per-room Jupyter token still stands between rooms. Only DNS on port 53 stays open to any address, because on some machines Docker's resolver forwards queries from the room's own network.
The server installs this block itself: a short-lived privileged helper container adds rules to chains of its own, COLLOQ-ROOMS-*, reached from DOCKER-USER and INPUT, with the same iptables the Docker daemon uses. It is built for colima, Docker Desktop and Docker on Linux; rootless Docker, podman and Docker Desktop with Enhanced Container Isolation don't allow the helper. If the rules can't be installed, rooms don't start, and the message in the room says why. For a trusted class that needs the local network, the line COLLOQ_ROOM_NETWORK=open in .env lifts the block, and colloq doctor then points it out. A room container started by an older version keeps its old profile until it stops; the next start recreates it.
Unlike a production room, the root filesystem stays writable so that %pip install works, and nothing limits how much disk a room uses. So a local class is still meant for a trusted setting — “my class on my computer”; for an open audience, use a server install.