phobos.sh
One command in front of yours. Everything else follows from the policy.
phobos.sh reads the configuration, builds one description of the run, and then hands your
command down a chain of layers, each of which does its part and passes the rest on.
phobos.sh is the entry point. Everything else in this section is a layer it assembles, and
each of those layers can be run on its own where you need to isolate a failure.
Before the first run
A bare checkout cannot run Phobos, deliberately. Two things are missing from it:
- The compiled programs.
phobos-landlock-filesystem-and-networksystem, the connect guard and the process-group lock are built from the source underprotecter/src/and never committed. Where the connect guard, the Landlock program or the process-group lock is missing, the layer that needs it ends the run rather than running without it. - The base policy.
phobos-policysystem.shfinds it by globbingBase*.cfgbeside itself, and a checkout keeps those files inprotecter/src/config/rather than inprotecter/src/. A run from a checkout is refused withPHB-EPOLICYinstead of running unprotected.
The delivery vehicle is the run-phase image, which compiles both and puts the shipped policy where the scripts look for it. Build it from the repository root:
.github/scripts/assemble-run-phase-context.sh build/run-phase-context
docker compose -f docker/protecter/java/docker-compose.yaml up --build
Inside that image PHOBOS_HOME is /var/tmp/opt/core, and phobos on PATH is a symbolic
link to the same script.
There is one image per language. The Java image is built on Artemis's default Java image,
ls1tum/artemis-maven-template:java25-1 (Java Development Kit (JDK) 25, Maven 3.9.16, Gradle
9.8.0), pinned by digest. It carries the dependencies of the two reference exercises under
exercises/. They are checked against protecter/image/*-repository.sha256 when
the image is built, so a prune and a grading run read the same bytes and a build is offline. A Gradle wrapper before 9.1.0 cannot
run on JDK 25. A Gradle exercise needs org.gradle.daemon=false, a matching org.gradle.jvmargs
and org.gradle.internal.instrumentation.agent=false in its gradle.properties, and the same
heap in gradlew. Otherwise Gradle forks a daemon, and the timeout's group lock refuses the
setsid the daemon needs.
The Python image is built from docker/protecter/python/docker-compose.yaml in the same way.
It starts from the official Python image, not from Artemis's ls1tum/artemis-python-docker,
whose release is too old for the C products, so an Artemis Python exercise graded under Phobos
must select this image. Its shipped BaseLanguage-python.cfg is the layer pruner's result for
the Python reference exercise on this image, joined from an x86_64 and an aarch64 run. The
exercise passes under it in continuous integration (CI), and grading refuses a code path that
exercise never takes.
The C image for Artemis exercises that use the FACT template is built from
docker/protecter/c-fact/docker-compose.yaml and carries BaseLanguage-c-fact.cfg. That base permits
executing a program the run itself wrote, in the directory of the submission only. It did not permit this
before, and the FACT reference exercise is the reason: its tester compiles the submission and runs the result.
The R image for Artemis exercises that use the R template is built from docker/protecter/r/docker-compose.yaml
and carries BaseLanguage-r.cfg. It starts from the official Debian image with the R that Debian packages, not
from Artemis's artemis-r-docker, so an Artemis R exercise graded under Phobos must select this image. The
exercise installs its packages into R_LIBS_USER, a library under the working directory. The image does not
set that variable. The grading script sets it and creates the directory, as the reference's build_script.sh does.
The Swift image for Artemis exercises that use the Swift template is built from docker/protecter/swift/docker-compose.yaml
and carries BaseLanguage-swift.cfg. It starts from the official Swift image and holds the three packages the test
package needs, so a build fetches nothing. Like the FACT base, this base permits executing a file the run itself wrote
in the directory of the solution only, because SwiftPM runs the manifest it compiles. The grading script points
TMPDIR into that directory, HOME into the working directory, and SwiftPM at the packages the image holds with
a mirror file, as the reference's build_script.sh does.
The GCC image for Artemis exercises that use the GCC template is built from docker/protecter/c-gcc/docker-compose.yaml
and carries BaseLanguage-c-gcc.cfg. It starts from the official Debian image, not from Artemis's
artemis-c-minimal-docker, so an Artemis GCC exercise graded under Phobos must select this image. Like the FACT
base, this base permits executing a file the run itself wrote, in the directory of the solution only. It grants
/dev/pts the ioctl right, because the tester starts the program on a pseudo-terminal. The tester's sanitizers
need the address-space cap off, so the exercise's configuration sets mem_mb=0 in [limits].
The C++ image for exercises that use the C++ template is built from docker/protecter/cpp/docker-compose.yaml and
carries BaseLanguage-cpp.cfg. It adds CMake, g++ and Catch2 to the GCC image's tools. CMake builds into the tests
directory, so this base permits executing a file the run wrote there, and nowhere else. It reads /proc whole, for
the sanitizer runtime. The exercise's configuration sets mem_mb=0 here as well.
The generic image, for an exercise in none of these languages, is built from
docker/protecter/generic/docker-compose.yaml and carries BaseLanguage-generic.cfg. It starts from the official
Debian image and carries bash, coreutils, findutils, grep, sed, gawk, diffutils and make, and its base grants what a
shell reference exercise needs of them. The base grants /usr/bin as a directory, so any program there can run,
but none of gcc's internals or the C headers, so Landlock refuses a C compilation. An exercise that needs more widens
the base through its own configuration, or runs in an image of its own with --image.
Running a command
${PHOBOS_HOME}/phobos.sh --config exercise.cfg -- ./gradlew test
The shipped base policy is applied without being named. --config is for the exercise's own
configuration, applied on top of that base, never for the base itself: naming a base file
there applies it a second time.
--config is not a grading runWithout one, Phobos takes its most restrictive shape. Every [connect], [bind] and [accept]
rule the base granted is dropped, so the command reaches no network at all, loopback included,
and cannot bind or listen on any port. A Gradle build cannot reach its own daemon. The
filesystem keeps what the base granted, because a command whose own binary and libraries were
denied could not start at all. Give the exercise's own configuration for a real run.
When no configuration names a value, the run is bounded by a default:
| Bound | Default | Applied as |
|---|---|---|
timeout | 600 seconds | wall-clock time |
mem_mb | 8192 | ulimit -v, the virtual address space |
cpu | 600 seconds | ulimit -t, CPU time across every thread |
nproc | 256 | ulimit -u, counted per user |
nofile | 1024 | ulimit -n |
fsize_mb | 256 | ulimit -f, the size of any single file |
A configuration that names a larger value wins over the default, and one that names 0 switches
that limit off and wins over it too. The default is a fallback used only where no configuration names the limit. A configuration can name a lower value, and that value then applies. It is never a cap.
Both spellings of the command work. Everything after -- is the command and its arguments; if
you leave -- out, the first word that is not an option becomes the command and everything
after it becomes its arguments. An unknown option is refused rather than treated as the
command, so a mistyped flag fails with a message instead of ending up in argv.
The environment the command sees
The current directory of a run can be the sandboxed command's own tree. Before it runs any
program, phobos.sh, like every layer started on its own, therefore cleans the environment it
starts in:
- It keeps only the absolute entries of
PATH, dropping., any other relative directory, an empty entry and one beginning with~. - It unsets
CDPATH. - It unsets
TMPDIR,HOSTALIASESandTZDIRwhere they are not absolute, and keeps only the absolute entries ofGCONV_PATH,LOCPATHandNLSPATH.
Each entry point says on standard error what it removed, apart from CDPATH and a PATH bash
invented because the environment had none. The command gets the cleaned
environment too, so a command that relied on . in PATH has to be named by its path, as
./gradlew is above. A PATH with no absolute entry ends the run with PHB-ERUNTIME, because
a lookup through an empty PATH searches the current directory. Where the environment had no PATH at
all, the command still gets none. phobos.sh --help describes this under ENVIRONMENT.
The interpreter is not looked up either. Every script under protecter/src/ begins with #!/bin/bash.
An entry point started as a program therefore runs the system's bash whatever the caller's PATH
holds, and so does every layer it starts afterwards by its absolute path. The run-phase image, and
every image built on it, has bash at /bin/bash.
No script can clean what takes effect before it runs. Start phobos.sh without BASH_ENV,
LD_LIBRARY_PATH, LD_PRELOAD or LD_AUDIT. A minimal environment of absolute values meets all
of them:
env -i PATH=/usr/local/bin:/usr/bin:/bin ${PHOBOS_HOME}/phobos.sh --config exercise.cfg -- ./gradlew test,
adding back only what the command needs, such as HOME or JAVA_HOME.
Which configurations apply, in which order
| Order | What | Where it comes from |
|---|---|---|
| 1 | Base policy | every Base*.cfg beside phobos-policysystem.sh, in sorted order |
| 2 | Task configurations | each --config file, in the order given |
| 3 | Tail flags | TailPhobos.cfg beside phobos-policysystem.sh, or --tail-flags-file |
The model is additive in every dimension. Filesystem paths and network rules are unioned, and for the timeout and each resource limit the largest value any configuration names wins, where a zero switches that limit off and beats every finite value. A task configuration cannot narrow below the base, which is exactly why it is trusted input.
Base*.cfg per runtime environmentThe base files are found by a glob and unioned. A BasePhobos.cfg left beside a
BaseLanguage-java-gradle.cfg gives a Java run the paths of every other language too, which is a
wider sandbox that looks like a working one. The discovery phase writes every alternative into
one directory on purpose; choosing between them is the packaging step.
Switching a layer off
Every restriction is applied by default. Each can be disabled on its own, which is how you find
out which layer a failure belongs to. The timeout, network and resource layers drop out of
the chain when disabled. The filesystem layer is the exception: it still runs the command and
counts denials, and -nfr only leaves Landlock out. Every one of them is recorded on standard
error, so a run with a layer off cannot look like an ordinary one in a log.
| Option | Short | Disables |
|---|---|---|
--no-filesystem-restriction | -nfr | the Landlock filesystem ruleset. The port rules are unaffected. |
--no-networksystem-restriction | -nnr | the whole network restriction: the connect guard and the Landlock port rules |
--no-timeoutsystem-restriction | -ntr | the timeout, and with it the process-group lock |
--no-resourcesystem-restriction | -nrr | the resource limits |
--no-restriction | -nr | every layer at once, even where a base policy is present |
--no-restriction runs the command raw, and it says so on standard error in a block that names
every layer it switched off. It is handled before any specification directory is created and
before the configurations are read, so a raw run leaves nothing behind. It is refused outright
beside --config, because giving a policy says a confined run was meant. That refusal is what
catches -nr typed where -nrr was meant: the two are one character apart, -nrr switches off
only the resource limits and -nr switches off the entire sandbox.
The other options
| Option | What it is for |
|---|---|
--help | Print the manual and end with status 0. Every script here, phobos.sh and each layer alike, has one: it names every flag the script parses with its default, both operating modes, the specification files it reads and the exit statuses it can end with. |
--debug | Print on standard error what each layer does and runs, and have phobos-landlock-filesystem-and-networksystem and the connect guard report verbosely too; the guard then names each destination the allow-list does not name, by address and port. It prints the whole effective policy, so it is for diagnosis rather than for a production log. |
--resolver <ip[:port]> | The Domain Name System (DNS) resolver the egress broker resolves an exact [connect] host name through. The default DNS port is added where no port is given. |
--spec-parent <path> | Where the run's specification directory is made. The default is /var/tmp, and it must lie outside every write path. |
--landlock-bin, --connect-guard-bin, --pgroup-lock-bin | Which program each layer uses. The default for each is the one beside the script. |
--timeout-bin, --haproxy-bin | The timeout tool and the HAProxy the broker and the inbound filter run as. The default for each is that name on PATH. |
--tail-flags-file <path> | The tail flags file, in place of TailPhobos.cfg. |
Every one of these is taken from the command line and never from the environment. A value left
in the environment cannot change which program applies the sandbox, and --debug in particular
can only be switched on by the flag.
What the layers do, in order
phobos.sh -> phobos-timeoutsystem.sh -> timeout -> phobos-seccomp-timeoutsystem -> phobos-networksystem.sh
-> phobos-seccomp-networksystem -> phobos-landlock-filesystem-and-networksystem --no-filesystem
-> phobos-filesystem.sh -> phobos-resourcesystem.sh -> phobos-landlock-filesystem-and-networksystem -> your command
The network layer contributes two links: the connect guard, which it puts in front, and a
Landlock ruleset of its own carrying the port rules, which composes with the filesystem
layer's by intersection. That ruleset is applied on every run, even when the policy names no
port, because it is what closes bind. The network layer runs the rest of the chain as a child and
waits for it, rather than replacing itself with it, because it starts HAProxy processes and adds
/etc/hosts lines that must not outlive the run. When the command ends it stops the broker and
the inbound filter it started, and the lines it added are removed with the specification
directory.
phobos.sh builds the run's specification with phobos-policysystem.sh, then assembles that chain
from the flags. The resource layer is not a link of the chain: the filesystem layer starts it
as the last step before phobos-landlock-filesystem-and-networksystem, so the limits bind the command and none of the
helpers around it.
Reading the result
Standard output carries the command's own output and nothing else. Every message from Phobos goes to standard error, and the exit status is the contract to read.
| Status | Meaning |
|---|---|
| the command's own | Phobos did not stop the run |
128 plus a signal | a SIGTERM, SIGHUP, SIGINT or SIGQUIT sent to phobos.sh was passed on to the command, which died of it |
2 | a script was called the wrong way |
11 | PHB-EPOLICY: the policy is invalid or cannot be enforced as written |
14 | PHB-ETIMEOUT: the run passed its timeout and was stopped |
15 | PHB-ERUNTIME: something Phobos needs is missing or cannot be started |
16 | PHB-ESTATUS: the command ran, but its exit status could not be read, so the run cannot say whether it succeeded |
125 | phobos-landlock-filesystem-and-networksystem, the connect guard or the group lock refused to set the sandbox up |
127 | the command itself could not be executed |
A run whose clean-up fails, because the specification directory or the run's /etc/hosts lines
cannot be removed, ends with 15 even where the command succeeded. A command that ignores the
signal goes on until its time limit, and SIGKILL cannot be passed on.
One more report carries no status of its own. When a run blocked anything, a closing line,
Phobos Security Summary: Phobos blocked <n> actions of the program, ... (PHB-EDENY), counts per
layer what the supervisor decided. The words a command prints itself count for nothing, and the
count never changes the exit status.
Further reading
- Troubleshooting: what each message means and what to do next
- Policy Reference: every section of a configuration file
- Life of a sandboxed run: the same chain, from the inside