The code that runs inside TUFF's sandbox: a small Linux VM started with
Apple's container tool. When the
sandbox setting is on (Settings > Tools; see
docs/WEB_SANDBOX.md), web search, page reads
and PDF reading go through this server instead of TUFF's own process. That is
the web profile. The same image, started a second time with
SANDBOX_PROFILE=files, is the files profile: a VM with no network that
reads the folders picked for local file search (see "The files profile" below).
This repository holds the VM's side. The app's side is in
Sources/TUFFApp/Core/Sandbox/
in the TUFF repository.
| File | What it is |
|---|---|
Containerfile |
The image: pinned base image, Debian packages from a dated snapshot, Python packages pinned by hash. |
entrypoint.sh |
Loads the firewall of the profile (SANDBOX_PROFILE), refuses to start without it, then starts the server. |
firewall-web.nft |
Web profile: public addresses only; the Mac and the local network are refused; one inbound port, from the gateway only. |
firewall-files.nft |
Files profile: nothing outbound at all (loopback and replies only); the same single inbound port. |
default_gateway.py |
Reads the VM's default gateway for the inbound rule. |
server.py |
The API: GET /health, POST /v1/page, POST /v1/http (web) or POST /v1/files/list, POST /v1/files/passages (files), with signing both ways. Its docstring is the specification. |
sandbox_client.py |
A small client that signs a request and checks the answer. Used by the self-test and the tests. |
test-vectors/signing.json |
Fixed signature examples. The server, this client and the Swift code must all reproduce them. |
requirements.in, requirements.txt |
Direct packages, and every package pinned with hashes. |
test_server.py, test_signing.py, test_infra.py |
Python tests. |
selftest_pdf_bomb.py |
Run inside the VM by the self-test: a decompression-bomb PDF must end only its child process. |
Tests need nothing but python3. Tests that need pypdf or trafilatura skip
when they are missing; in a virtual environment,
pip install trafilatura==2.3.0 pypdf==6.21.2 runs them all. (Do not use
requirements.txt for that on a Mac: its hashes are for the image's Linux
platform.)
python3 test_server.py
python3 test_signing.py
python3 test_infra.pyOn a Mac with Apple container 1.5 or newer, scripts/selftest.sh all
builds the image, starts it, checks the firewall, the missing privileges, the
blocked Mac addresses and every endpoint, and stops it again; then it does the
same for the files profile (files-start, files-selftest, files-stop,
or files-all for that part alone): sample files mounted read-only, nothing
reachable from inside the VM, a symbolic link to /etc/passwd neither listed nor
read, a PDF bomb refused. Set TUFF_SANDBOX_IMAGE to check a pulled image
instead of building one.
To change the image (a new Python package, a newer base image) and ship it, see PUBLISHING.md: rebuild, publish, pin the new digest in the app.
- Every request is signed (
X-Sandbox-Nonce,X-Sandbox-Time,X-Sandbox-Auth) with a secret that only the app and the VM know, and every answer is signed back, so the app can tell the sandbox from another program on the port. A request more than 60 seconds old, or one whose nonce was seen, is refused. /v1/pageanswers finished text only. HTML and PDFs are read in a child process with time (and, for a PDF, memory) limits./v1/httpreacheshtml.duckduckgo.com,api.search.brave.comandapi.tavily.comover https, with a short header allow-list; a key header goes to its own provider only. The request is{"url", "method": "GET" or "POST", "headers"?, "body_base64"?, "max_bytes"?, "timeout_seconds"?}; a POST body goes inbody_base64(base64 of the bytes; the whole request, headers' values and URL included, may be at most 64 KiB, which leaves a little under 48 KiB of decoded body), and a field namedbodyis refused as unknown. The answer is{final_url, status, content_type, body_base64}.- Both refuse private, local and metadata addresses, and the Mac's own
addresses (
SANDBOX_BLOCKED_ADDRESSES), in the server and again in the VM's firewall.
SANDBOX_PROFILE is web (the default) or files; any other value, an empty
one included, stops the entrypoint and the server before they do anything. The
files VM has no network: firewall-files.nft drops everything outbound except
loopback and replies on the connection the app opened, the app starts it with
--no-dns, and SANDBOX_BLOCKED_ADDRESSES is not used (it is ignored if
given). The folders the user picked are mounted read-only at
/files/<folder id>, where the id is a lower-case UUID. The VM only reads: it
lists files and turns them into passages; TUFF's index stays on the Mac. Same
signing, request limits, connection cap and dropped privileges as the web
profile (privileges are dropped before any file is read).
/health answers {"status", "profile": "files", "api": 1, "pdf", "blocked_addresses": 0, "folders": [ids found under /files]}. The two web
paths are unknown here, and the two paths below are unknown in the web profile.
POST /v1/files/listtakes{"folder", "cursor"?, "limit"?}(limit1 to 2000, default 1000) and answers{"entries": [{"path", "size", "mtime"}], "next_cursor": path or null, "unreadable": int}.unreadableis how many directories on this page could not be listed or opened (permission denied or any other error), each counted once; they are skipped, so a folder that is only partly readable lists what it can, and the count says it is not complete. A directory that the next page walks again is counted there, not on both. If the folder's own top level cannot be listed or opened for lack of permission, the answer is 403folder_unreadable("the folder cannot be read"); a folder that is not mounted stays 404not_found. Only regular files are listed, depth first, by name at every level, files and directories interleaved. Hidden names, symbolic links,.git,node_modules,build,distand the other build directories listed inserver.py, anything deeper than 24 levels and files without an extension (other thanREADME*) are left out. Paging keeps no state:cursoris the last path of the previous page, and the walk resumes after it without reading the directories that sort before it. The server sets no cap on the total; the app does. One page does a bounded amount of work: it ends after 1 MiB of JSON-encoded entries (paths with non-ASCII characters take up to 12 bytes a character), after 50,000 directory entries examined, or after 15 seconds, and carries on throughnext_cursor. So a page may hold fewer thanlimitentries, even none, withnext_cursorset; the cursor is then the last entry examined, which need not be a listed file (it can be a hidden name, a skipped directory or a file without extension), and keep following it until it is null. A page always examines at least one new name, so its cursor moves on even when the time is already used up.POST /v1/files/passagestakes{"folder", "path"}and answers{"size", "mtime", "passages": [{"page", "line_start", "line_end", "text"}]}. A.pdfis read page by page with pypdf in a child process (60 seconds, 512 MiB, one PDF at a time; at most 64 MiB and 500 pages); anything else is read as strict UTF-8 text of at most 2 MiB. Passages of about 1200 characters are cut on line boundaries the way the app does it (AppLocalIndexer.passages), a single longer line is cut into pieces, and a passage can be a little under 2400 characters long (the cap on its text is 2500). Text passages carry exact 1-based line numbers of the file (a CRLF ends one line), PDF passages carry the page number. CR, VT, FF and U+0085 in the text become a space and the other control characters except tab and newline are removed. A PDF with more than 4,000,000 characters of text in all is refused (oversized).sizeandmtimeare those of the open file and are checked again after reading; a file that changed meanwhile ischanged_while_reading.- Errors are
{"error": {"message", "code"}}:invalid_folderandinvalid_path(400),folder_unreadable(403),not_found(404),not_a_file,oversized,too_many_passages,unreadable,empty(422),changed_while_reading(409),pdf_busy(503). Messages never contain file contents.
A path is relative, / separated, 1 to 1024 characters, without empty, . or
.. components or control characters. Every component is opened with
O_NOFOLLOW relative to the folder's descriptor, so a symbolic link anywhere on
the path, inside the folder or pointing out of it, is refused (not_a_file).
/v1/files/passages reads any regular file inside a mounted folder that it is
asked for, including ones the listing leaves out (hidden files, files in skipped
directories such as node_modules, files deeper than 24 levels, files without
an extension); the listing is a convenience of the app, not an access rule.
A hard link cannot be told from the file it points to: a hard link inside the
picked folder to a file elsewhere on the same disk is read like any other file.
The files VM cannot reach the network, write to the folders, or leave them
through links; it can read every regular file inside a folder it was given,
and the secret it signs with is readable inside it, as in the web profile.
DNS goes through the Mac, so the Mac sees which names are looked up. Search
keys pass through the VM inside the one request that needs them. The Mac's own
addresses are blocked one by one: other machines on the Mac's network, and
addresses the Mac gains after the VM started (for example temporary IPv6
addresses, or the address of the VM network's own bridge, which exists only while a
container runs), are not covered by that list (the bridge's are private ranges,
refused anyway); the VM's firewall refuses private
ranges, but a LAN host with a global IPv6 address is reachable if the VM has
IPv6 at all. The server serves 32 connections at once and cuts a request that
has not arrived in full after 10 seconds, so a local program cannot exhaust its
process limit; one that keeps opening connections can still occupy all 32 and
make the server refuse the app's requests for as long as it does that. The secret is readable by every user inside
the VM (see the note in server.py). A program
running as the same Mac user could read the secret, but it could read the user's
files directly anyway.
TUFF is one project split across a few repositories:
| Repository | What's in it |
|---|---|
| TUFF | The Mac app, the tuff command and the inference engines |
| sandbox | The Linux VM that runs web and file search in isolation |
| homebrew-tap | The Homebrew cask |
| tuff-app.github.io | The website and benchmark leaderboard |
They all follow the same contributing guide, maintainer roles and code of conduct. Questions and ideas go in Discussions.
Please don't open a public issue for a security problem. Report it privately through GitHub's vulnerability reporting on the repository it affects, or on TUFF if you're not sure which. The security policy has the details.
Apache 2.0. Model weights keep their own terms.