Parser sandbox providers
Bubblewrap, landlock and Modal: choosing and setting up where parsers run.
Parser sandbox providers
The worker runs file parsers (poppler, pandoc, LibreOffice and similar) on untrusted input. A sandbox provider decides where and how those commands run. This page covers the four providers, how one is chosen, what production requires, and how to set up each. For what the sandbox protects and where it stops, see Security.
On this page
Providers
| Provider | DOCSLURP_PARSER_SANDBOX |
Filesystem | Network | Process isolation | Where it runs |
|---|---|---|---|---|---|
| Bubblewrap | bubblewrap |
Isolated | Fully blocked (full) |
Yes | Local |
| Landlock | landlock |
Isolated | TCP blocked (tcp-only) |
No | Local |
| Modal | modal |
Isolated | Fully blocked (full) |
Yes | Remote |
| None | none |
Not isolated | Not isolated (none) |
No | Local |
The network levels mean:
full: the parser has no network access of any kind.tcp-only: TCP connect and bind are denied. Other protocols are not restricted.none: no network isolation.
| Provider | Host requirements |
|---|---|
| Bubblewrap | Linux, bwrap installed, a kernel and container runtime that allow unprivileged user namespaces. In Docker this needs the security options in Requirements. |
| Landlock | Linux with a kernel that supports Landlock ABI v9, and the landrun binary. landrun v0.1.17 refuses to run on a kernel with a lower ABI; the probe tells you whether yours qualifies (the test host on kernel 7.2.5 works, Fargate’s kernel 6.1 does not). The worker images include landrun. landrun v0.1.17 is a dynamically linked release binary that needs glibc 2.38 or newer (Debian trixie, not bookworm). No namespaces or extra container options needed. |
| Modal | Outbound HTTPS from the worker to Modal, a Modal account and API token, and the modal npm package (an optional dependency of @docslurp/worker). |
| None | Nothing. Development only. |
The pinned landrun version, download URLs and sha256 sums are in worker/sandbox-tools.json. landrun is MIT-licensed; releases before v0.1.12 were GPL-2.0 and are not used. See THIRD_PARTY_NOTICES.md.
Selecting a provider
Set DOCSLURP_PARSER_SANDBOX to bubblewrap, landlock, modal or none. An unknown value stops the worker at start.
Unset, the default is bubblewrap on Linux, and none on other platforms outside production. Existing installs that never set the variable keep running under bubblewrap.
At start the worker loads the provider, probes it and checks the policy below. If any step fails, the worker exits. It never falls back to running parsers unsandboxed. On success it logs one line:
[parser-sandbox] provider=<id> filesystem=<bool> network=<level> processes=<bool> location=<local|remote>Each Temporal activity opens its own sandbox session, and the session closes when the activity ends, including when it fails or is cancelled.
Production policy
With NODE_ENV=production (which Compose sets), the provider must report:
- filesystem isolation, and
- network isolation of
tcp-onlyorfull.
Landlock reports tcp-only, so it passes. On a kernel without Landlock ABI v9 it never gets that far: the probe fails (see Landlock). Provider none is refused unless you set DOCSLURP_PARSER_SANDBOX_ALLOW_UNISOLATED=true. With that override, every start logs:
[parser-sandbox] status=unisolatedOutside production the policy is not enforced, but the probe still has to pass.
Configuration
Variables each provider reads. Secret values are never logged or included in error messages.
| Variable | Used by | Default | Meaning |
|---|---|---|---|
DOCSLURP_PARSER_SANDBOX |
all | see Selecting a provider | Provider id. |
DOCSLURP_PARSER_SANDBOX_ALLOW_UNISOLATED |
none |
unset | true allows provider none in production. |
DOCSLURP_PARSER_SANDBOX_ROOT |
bubblewrap, landlock | /tmp/docslurp-parser-sandbox |
Host directory for per-command scratch space. |
DOCSLURP_PARSER_SANDBOX_WRITABLE_PATHS |
bubblewrap, landlock | empty | Comma-separated private DocSlurp workspaces every parser command may write. |
DOCSLURP_LANDRUN_BINARY |
landlock | landrun |
Path or name of the landrun executable. |
MODAL_TOKEN_ID |
modal | required | Modal API token id. Secret. |
MODAL_TOKEN_SECRET |
modal | required | Modal API token secret. Secret. |
DOCSLURP_MODAL_APP |
modal | docslurp-parsers |
Modal app that owns the sandboxes. Created if missing. |
DOCSLURP_MODAL_IMAGE |
modal | ghcr.io/docslurp/worker-full:<DOCSLURP_VERSION> in release images; required otherwise |
Image the sandboxes run. It must contain the parser binaries. |
DOCSLURP_MODAL_REGISTRY_SECRET |
modal | unset | Name of a Modal Secret holding REGISTRY_USERNAME and REGISTRY_PASSWORD, for pulling a private image. |
DOCSLURP_MODAL_CPU |
modal | 2 |
Requested CPU cores per sandbox. |
DOCSLURP_MODAL_MEMORY_MB |
modal | 4096 |
Requested memory in MiB per sandbox. |
DOCSLURP_MODAL_SESSION_TIMEOUT_SECONDS |
modal | 900 |
Maximum sandbox lifetime. Bounds billing if a session leaks. |
A required variable that is missing, or a number or boolean that does not parse, fails startup with Invalid sandbox configuration; ..., naming the variables.
Landlock
Set DOCSLURP_PARSER_SANDBOX=landlock. The worker images ship landrun at /usr/local/bin/landrun. Elsewhere, install the pinned release from worker/sandbox-tools.json and point DOCSLURP_LANDRUN_BINARY at it.
Landlock restricts the parser to read-only access to system runtime paths and the input files, and write access to its scratch and declared output directories. Like the bubblewrap launcher, each command gets a private directory under DOCSLURP_PARSER_SANDBOX_ROOT with HOME, TMPDIR, TMP, TEMP and the XDG_* directories pointed into it; it is removed when the command exits. The worker resolves DOCSLURP_LANDRUN_BINARY against its PATH once at start, so the probe and every command run the same landrun. A path containing a comma cannot be granted (landrun splits grant lists on commas), so such a command fails instead of running with the wrong grants. It denies TCP connect and bind. landrun v0.1.17 refuses to run unless the kernel supports Landlock ABI v9. On an older ABI the probe fails with missing kernel Landlock support. Got Landlock ABI vN, wanted {Landlock V9; ...}, and the worker refuses to start with landlock rather than running with weaker isolation. The probe tells you whether your kernel qualifies; the test host on kernel 7.2.5 works and Fargate’s kernel 6.1 (ABI v2) does not. If you see this, the kernel is too old for landrun v0.1.17: use bubblewrap on a host that allows user namespaces, or modal. It does not create namespaces, so it works in containers where bubblewrap cannot. It has no PID or IPC isolation, and the processes flag is false.
Modal
Modal runs each activity’s parser commands in a remote sandbox. Use it when the worker host cannot run bubblewrap or landlock, or when you want parsing off the host.
- Create a Modal API token and set
MODAL_TOKEN_IDandMODAL_TOKEN_SECRETon the worker. Keep them in your secret store, not in the repository. - Set
DOCSLURP_PARSER_SANDBOX=modal. - Choose the image. Release worker images set
DOCSLURP_VERSION, and the default image isghcr.io/docslurp/worker-full:<DOCSLURP_VERSION>, which.github/workflows/worker-images.ymlpublishes on the same release tag (see Releases). A worker run from source or a local image build has noDOCSLURP_VERSION, soDOCSLURP_MODAL_IMAGEis required there; without it the worker stops at start withInvalid sandbox configuration; missing required: DOCSLURP_MODAL_IMAGE. Modal pulls the image without credentials, so the GHCR package must be public. For a private image, create a Modal Secret withREGISTRY_USERNAMEandREGISTRY_PASSWORDand setDOCSLURP_MODAL_REGISTRY_SECRETto its name. - Start the worker. The probe loads the
modalpackage and opens the app (DOCSLURP_MODAL_APP, created if it does not exist).
How it behaves:
- The sandbox is created with the network blocked.
- Only the command’s input files (file arguments inside the worker’s DocSlurp temp workspaces) are uploaded, at the same paths, before each command. Parsers, scripts, Node and Python come from the image, never from the worker host. After the command, only the files it created or changed under the declared writable paths are copied back, not the whole directory. A writable path the command never creates is not an error and produces no file.
- Time limits are enforced by Modal, which accepts only whole seconds, so each limit is rounded up to the next second. A command that exceeds its limit is killed as described below and reported like a killed process (exit code
null). - When one command is aborted (for example a stage’s per-command timeout) or times out, only that command fails; other commands in the same activity keep using the sandbox. The Modal SDK cannot kill one exec, so the worker starts each command in its own session with
setsid(from util-linux, which the worker image has) and kills the command’s whole process group with a second exec. A descendant that starts its own session or process group escapes that kill and runs until the sandbox closes. On an image withoutsetsid, only the command’s top process is killed. A command that dies from a signal reports 128 plus the signal number, as local commands do. - Reporting an abort can take up to about 20 seconds: up to 10 seconds for the kill exec, then up to 10 seconds for the killed command’s exec to end.
- An aborted or timed-out command copies nothing back. The files it created or changed in its writable directories are removed from the sandbox, so a retry does not find its partial output there. If other commands in the same activity are still running, that removal waits until none is, because their in-progress files cannot be told apart from the aborted command’s. An input the aborted command modified is uploaded again the next time a command uses it.
- Copy-back is per command, judged by what changed under its writable directories while it ran. When commands in one activity run at the same time and share a writable directory, a command can copy back a file another running command wrote meanwhile, including the partial output of a command that is later aborted. That copy is not removed from the worker host. It sits at the aborted command’s own output path, where a successful retry overwrites it. A command never copies back an input uploaded for another command. Modal reports file times in whole seconds, so if a command rewrites an existing file with the same size in the same second the worker recorded its writable directories, the change is not detected and the file is not copied back.
- The sandbox is terminated when the activity’s session closes, whether the activity succeeds, fails or is cancelled.
DOCSLURP_MODAL_SESSION_TIMEOUT_SECONDSis the backstop if the worker dies. - Each command logs one line:
[sandbox-usage] provider=modal cpu_seconds=<n> memory_gb_seconds=<n> activity=<id>The Modal SDK does not report actual usage. The figures are the requested CPU and memory multiplied by wall time, which is what Modal bills. For each command, including one that fails or is aborted, the worker also records one usage event with provider modal and metric sandbox_compute in the running stage’s usage ledger, tagged with the stage’s scope. The event carries the estimated provider cost in metadata.effectiveCostCents and is marked costRollup: "included" and billable: false, so it never adds customer credits, even with DOCSLURP_BILL_COMPUTE=true. A command run outside a pipeline stage (for example from a CLI) only logs the line.
While a Modal command runs, the worker reports progress to the stage’s stall watchdog every 30 seconds, up to the command’s own time limit. Long remote parses are not aborted as hung. Remote stdout and stderr are capped at WORKER_COMMAND_MAX_OUTPUT_BYTES, as local commands are.
Modal sends your document contents to a third party. Decide whether that fits your data before you enable it.
Where these providers work in containers
Results of local Docker checks on 2026-10-20. Image: Debian trixie-slim with bubblewrap and landrun v0.1.17. Docker 29.7.2, host kernel 7.2.5.
| Docker security options | Bubblewrap | Landlock |
|---|---|---|
| Default seccomp | Fails: bwrap: No permissions to create a new namespace |
Works: filesystem rules enforced, TCP connect denied |
seccomp, apparmor and systempaths all unconfined |
Works | Works |
Takeaway: landlock works in containers without unconfined security options. Bubblewrap worked only when seccomp, apparmor and systempaths were all unconfined in this test. Each option was not tested alone. compose.selfhost.yml keeps bubblewrap as the default and uses systempaths=unconfined plus a custom seccomp profile (see Security).
Raw probe output, default seccomp
== uname
7.2.5-3-omarchy
== lsm
cat: /sys/kernel/security/lsm: No such file or directory
== userns_clone
1
256370
== unshare --user true
unshare: unshare failed: Operation not permitted
exit=1
== bwrap --unshare-all --ro-bind / / true
bwrap: No permissions to create a new namespace, likely because the kernel does not allow non-privileged user namespaces. See <https://deb.li/bubblewrap> or <file:///usr/share/doc/bubblewrap/README.Debian.gz>.
exit=1
== landrun --version
landrun version 0.1.17
exit=0
== landrun fs allow /usr
exit=0
== landrun fs deny (read /etc/hostname without /etc)
cat: /etc/hostname: Permission denied
exit=1
== curl without landrun (baseline)
200
exit=0
== landrun tcp deny (curl must fail)
curl: (7) Failed to connect to example.com port 443 after 0 ms: Could not connect to server
exit=7Raw probe output, seccomp, apparmor and systempaths unconfined
== uname
7.2.5-3-omarchy
== lsm
cat: /sys/kernel/security/lsm: No such file or directory
== userns_clone
1
256370
== unshare --user true
exit=0
== bwrap --unshare-all --ro-bind / / true
exit=0
== landrun --version
landrun version 0.1.17
exit=0
== landrun fs allow /usr
exit=0
== landrun fs deny (read /etc/hostname without /etc)
cat: /etc/hostname: Permission denied
exit=1
== curl without landrun (baseline)
200
exit=0
== landrun tcp deny (curl must fail)
curl: (7) Failed to connect to example.com port 443 after 0 ms: Could not connect to server
exit=7AWS Fargate
Run on 2026-10-20 in us-west-1, platform 1.4.0, kernel 6.1.188, 0.25 vCPU / 0.5 GB, with the same image (Debian trixie-slim, bubblewrap, landrun v0.1.17).
| Platform | Bubblewrap | Landlock |
|---|---|---|
| AWS Fargate | Fails: no user namespaces (bwrap: No permissions to create a new namespace) |
Fails: landrun v0.1.17 needs Landlock ABI v9, Fargate exposes ABI v2 (missing kernel Landlock support) |
Takeaway: on Fargate neither local provider works. Bubblewrap cannot create user namespaces. Landlock fails the probe, and ABI v2 has filesystem rules only and no network rules, so it could not pass the production policy even with --best-effort. Use the modal provider on Fargate.
Raw probe output, AWS Fargate
== uname
6.1.188
== lsm
cat: /sys/kernel/security/lsm: No such file or directory
== userns_clone
cat: /proc/sys/kernel/unprivileged_userns_clone: No such file or directory
16384
== unshare --user true
unshare: unshare failed: Operation not permitted
exit=1
== bwrap --unshare-all --ro-bind / / true
bwrap: No permissions to create a new namespace, likely because the kernel does not allow non-privileged user namespaces. See <https://deb.li/bubblewrap> or <file:///usr/share/doc/bubblewrap/README.Debian.gz>.
exit=1
== landrun --version
landrun version 0.1.17
exit=0
== landrun fs allow /usr
[landrun:error] 2026/10/20 16:25:40 Failed to apply sandbox: failed to apply Landlock restrictions: missing kernel Landlock support. Got Landlock ABI v2, wanted {Landlock V9; FS: all; Net: all; Scoped: all}
exit=1
== landrun fs deny (read /etc/hostname without /etc)
[landrun:error] 2026/10/20 16:25:40 Failed to apply sandbox: failed to apply Landlock restrictions: missing kernel Landlock support. Got Landlock ABI v2, wanted {Landlock V9; FS: all; Net: all; Scoped: all}
exit=1
== curl without landrun (baseline)
200
exit=0
== landrun tcp deny (curl must fail)
[landrun:error] 2026/10/20 16:25:40 Failed to apply sandbox: failed to apply Landlock restrictions: missing kernel Landlock support. Got Landlock ABI v2, wanted {Landlock V9; FS: all; Net: all; Scoped: all}
exit=1Raw probe output, AWS Fargate, landrun with --best-effort (diagnostic only)
Diagnostic only: the provider never uses --best-effort.
== best-effort fs allow
exit=0
== best-effort fs deny
cat: /etc/hostname: Permission denied
exit=1
== best-effort tcp (ABI v2 has no net rules)
curl: (23) client returned ERROR on write of 577 bytes
200
exit=23curl exit 23 means /dev/null was not writable inside the sandbox. The HTTP 200 shows TCP was not blocked.
If the landlock probe fails with the following message, the kernel is too old for landrun v0.1.17:
missing kernel Landlock supportNot yet tested:
| Platform | Status |
|---|---|
| Google Cloud Run | Not yet tested |
Do not assume landlock works on Cloud Run until it has been checked. Choosing isolation per platform lists what each platform’s own boundary gives you. The probe at worker start tells you for your platform. Build the worker first (pnpm --filter @docslurp/worker build); the probe and scripts/check-worker-prerequisites.mjs --sandbox both run the compiled worker/dist/. Then run the probe without starting the worker:
node worker/dist/sandbox/probe-cli.jsIt prints ok <provider> and exits 0, or prints the failure detail and exits 1. After the probe succeeds it also opens one session and runs ffprobe -version, ffmpeg -version, pdftoppm -v and pandoc --version inside it, for each of those that is installed. A library the sandbox cannot see then fails the check, not every upload of that format. If one fails, the probe prints <name> fails inside the parser sandbox (exit <status>): <first line of stderr> and exits 2. For a remote provider such as Modal it skips these checks without opening a sandbox, because the binaries live in the provider’s image, and prints parser binary checks skipped: remote provider. The Modal probe also does not pull the image, so a wrong or unpullable DOCSLURP_MODAL_IMAGE first shows up as modal sandbox creation failed in the first activity that runs a parser.
Running parsers in a separate Fargate task (no bubblewrap or landlock)
DocSlurp does not yet have a provider that launches such tasks. This section records a spike that shows the approach works, so you can judge it. It is planned as the platform-task provider: one ECS task, Cloud Run job or Fly Machine per job, with operator attestation plus an in-job probe that fails closed if the network or credential endpoints are reachable. Until it exists, on Fargate use modal.
The spike ran on 2026-10-20 in us-west-1, platform 1.4.0, kernel 6.1.188, with the same image as above. A task ran the probe script with no bubblewrap and no landlock, relying only on the task’s own settings.
| Check | Result |
|---|---|
| Identity and capabilities | uid=65534(nobody), all capability sets empty (CapEff 0000000000000000), seccomp mode 2. |
| Root filesystem | Read-only: writing /probe-write fails with Read-only file system. |
| Scratch volume | Writable by the non-root parser only after a trusted init container runs chown 65534:65534 /scratch. The probe wrote each check’s output to $TMPDIR/o with TMPDIR=/scratch (the bind volume). Without the init container every check failed with cannot create /scratch/o: Permission denied. With it, every output was written to and read back from /scratch. The “write to /tmp” line in the raw output below failed with Read-only file system because /tmp is part of the read-only root in that run, not the scratch volume. |
| Internet TCP | Blocked by the security group. https://example.com and 1.1.1.1:443 both time out. |
| DNS | Still resolves example.com. The VPC resolver answers even with no egress. This is a data exfiltration path. Use Route 53 Resolver DNS Firewall if it matters. |
| EC2 metadata (IMDS) | Unreachable: 169.254.169.254 refuses the connection. |
| Task credentials endpoint | AWS_CONTAINER_CREDENTIALS_RELATIVE_URI is unset and the endpoint returns 404 when the task has no task role. |
| Task metadata v4 | Reachable through ECS_CONTAINER_METADATA_URI_V4. It returns cluster and task ARNs, no secrets. |
For contrast, a default task (root, default capabilities, public IP, open egress) ran as uid 0 with CapEff 00000000a80425fb, a writable root filesystem, and internet access (example.com 200, 1.1.1.1 301).
Task-definition settings used:
readonlyRootFilesystem: true, user65534, and Linux capabilities dropALL.- No task role. The execution role only pulls the image and writes logs.
- A scratch bind volume. Fargate bind volumes are root-owned and do not copy the image directory’s ownership, so a trusted init container (root,
CHOWNonly,essential: false, with the parser containerdependsOnit atSUCCESS) chowns the volume for the non-root parser. assignPublicIp: DISABLED. The security group allows egress only on 443, to the VPC CIDR (interface endpoints forecr.api,ecr.dkrandlogs) and to the S3 prefix list (gateway endpoint).
With no internet egress, the task cannot pull its image or write logs unless those VPC endpoints exist. Plan for them. The security-group rules above are the only egress.
Start latency, from RunTask to the container started:
| Case | Time |
|---|---|
| Cold, image pulled through the VPC endpoints (3 runs) | 43 to 48 s (about 33 s of it the pull) |
| Warm (2 runs, includes the init container) | about 12.5 s |
That suits large documents or media jobs, and is slow for small PDFs.
Scratch-volume evidence from the earlier revision of the task, before the init container was added, with the same probe writing to /scratch/o:
cannot create /scratch/o: Permission deniedThe spike removed every resource it created (cluster, task definitions, VPC endpoints, security group, ECR repository, log group, IAM role).
Raw probe output, AWS Fargate per-job hardened task
== id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
== kernel
6.1.188
== status
CapPrm:
0000000000000000
CapEff:
0000000000000000
CapBnd:
0000000000000000
NoNewPrivs:
0
Seccomp:
2
== visible pids
3
== write to root fs (/probe-write)
sh: 1: cannot create /probe-write: Read-only file system
exit=2
== write to /tmp (scratch volume)
sh: 1: cannot create /tmp/probe-write: Read-only file system
exit=2
== internet https (example.com)
curl: (28) Connection timed out after 5001 milliseconds
000
exit=28
== internet tcp by IP (1.1.1.1:443)
curl: (28) Connection timed out after 5002 milliseconds
000
exit=28
== dns lookup
2606:4700:10::ac42:93f3 example.com
2606:4700:10::6814:179a example.com
exit=0
== EC2 IMDS 169.254.169.254
curl: (7) Failed to connect to 169.254.169.254 port 80 after 0 ms: Could not connect to server
exit=7
== credentials uri env
<unset>
== ECS credentials endpoint
404 page not found
exit=0
== ECS task metadata v4 (task)
(JSON with cluster and task ARNs; account id and task ids omitted)
exit=0
== env keys
AWS_DEFAULT_REGION AWS_EXECUTION_ENV AWS_REGION ECS_AGENT_URI ECS_CONTAINER_METADATA_URI ECS_CONTAINER_METADATA_URI_V4 HOME HOSTNAME PATH PWD TMPDIR Overhead
Measured on one machine, with the local providers and Modal. These figures are for a small PDF. Your hardware and documents will differ.
- Fixture:
form-rotated-notes.pdf. - Repeats: 3. Figures are medians.
- Commands per run:
pdfinfo,pdftotext,pdftoppm -r 72 -f 1 -l 1. - landrun v0.1.17.
| Provider | Isolation | Session open (ms) | Median command (ms) | Total (ms, sum of medians) |
|---|---|---|---|---|
| bubblewrap | fs=true net=full proc=true local | - | 30.3 | 137.7 |
| landlock | fs=true net=tcp-only proc=false local | - | 13.8 | 87.9 |
| none | fs=false net=none proc=false local | - | 10.9 | 78.9 |
Local providers spawn a process per command, so there is no session open cost (-).
Modal figures are partial. They were measured on the stock debian:trixie-slim image, because the worker image was not yet published, and only for opening a session and running cat on the PDF. Session open was about 445 ms and the cat command about 544 ms. Real parser timings through Modal are pending the published worker image.
To measure your own:
pnpm --filter @docslurp/worker bench:sandbox -- --file <pdf> --providers bubblewrap,landlock,none --repeat 3A provider whose probe fails is skipped, with the reason printed.
Limits
Not every code path goes through a provider:
- In-process Node parsers (Kreuzberg and LiteParse) run inside the worker process. No provider sandboxes them.
- The vector writer and the embedding pool run outside the sandbox. The writer needs object storage access.
Treat these as part of the worker’s trust boundary. Security has details.
Adding a provider
Providers live in worker/src/sandbox/providers/<id>/index.ts.
- Implement
ParserSandboxProvider(worker/src/sandbox/types.ts):id,configSchema(each property names itsenvvariable and marks secrets withsecret: true),readConfig,probeandopenSession. A local session returnsspawn; a remote session returnsrun. - Report honest
SandboxIsolationvalues fromprobe. The production policy trusts them. - Register the id in
SANDBOX_PROVIDER_IDSand add a loader inworker/src/sandbox/registry.ts. - Pass the shared contract: call
defineSandboxContract(worker/src/sandbox/__tests__/contract.ts) from a test, ascontract-local.test.ts,contract-landlock.test.tsandcontract-modal.test.tsdo. - Never put secret values in probe details or error messages.
Source: worker/src/sandbox/, worker/sandbox-tools.json, Security.