This lesson needs macOS: it uses the built-in sandbox-exec tool to deny network access. On Linux or Windows, see How to run Redact offline instead.
From the start we’ve said nothing leaves your machine. That’s easy to say and harder to show. In this lesson we prove it: we run the analysis in a sandbox where reaching the internet is impossible, and check it still works. Checkpoint: redact-10-offline, after redact-09-evaluation

What does “offline” actually prove?

Unplugging the Wi-Fi is a weak test. Something could quietly wait for the network to come back, or you might forget to unplug it. A stronger test is to make the operating system refuse every outside connection for this one process, and watch it succeed anyway. That’s what macOS’s sandbox-exec does. It runs a command under a small policy, and ours says: allow everything, except network connections to anywhere that isn’t this computer. If Redact tried to call a server, the call would fail, and so would the run. Be clear about the limits. This proves what the Redact service could reach while it ran. It doesn’t prove the suggestions caught every private field, and it doesn’t cover the first run, which needs the internet once to download the models. More on this: About model downloads, caching and offline use.

1. Get this lesson’s code

Stop the app and the service if they’re running. From the repository root, redact-course, bring in this lesson’s commit:
No packages change, so there is nothing to install. To see every change, open the comparison on GitHub.

2. Read the sandbox policy

Open package.json and find qvac:offline:
Read the policy from the inside out. (allow default) lets the process do everything as normal. (deny network-outbound …) then refuses any outgoing TCP or UDP connection whose destination is not localhost. qvac:service:offline wraps the normal service in the same policy.

3. Read the proof script

Open qvac-service/offline-cli.ts. Before loading anything, it checks the models are already on disk:
assertWarmedCache in offline-proof.ts looks for the three model files by name:
Without the cache, an offline run can’t work, so it stops with a clear message instead of a confusing download error. Then it checks the sandbox really is on, by trying to reach the internet and expecting to fail:
If that request somehow succeeded, the proof would be meaningless, so the script refuses to go on. Finally it runs one real analysis with a second guard inside the process, withRemoteFetchBlocked, which makes any http: or https: fetch fail while the analysis runs. It prints how many blocks OCR found, which labels Laya suggested, and the timings.

4. Make sure the models are cached

You should see three files: one containing craft, one containing latin, and one ending laya-Q8_0.gguf. If any is missing, run one analysis in the app with the network on first.

5. Run one analysis with the network denied

With the service stopped:
It takes about half a minute. Open offline-receipt.json. You should see:
Under run there’s "status": "ready", "reasoningModel": "laya-421m-q8" and the timings. In our run OCR took about 16 seconds and Laya about 1.3 seconds, including loading. Those seven suggestions are the same ones you saw in the app, produced while the process couldn’t reach anything beyond this computer.

6. Run the whole app with a sandboxed service

Build the app once:
In one terminal, start the service inside the same sandbox:
In a second terminal, start the built app:

7. Run the browser receipt

In a third terminal, inside redact, install Playwright’s browser once:
The browser is about 550 MB and took about five and a half minutes to install in our testing. Then run the live test:
After about 20 seconds, with the models already loaded, it prints 1 passed. In that time the test drove the real app the way you would:
  1. It uploaded the chat, chose Private and typed a direction.
  2. It waited for local suggestions, accepted the name, rejected the email and accepted the rest.
  3. It added one manual mark, selected Done and saved the copy.
  4. It checked a pixel inside the name is black in the saved file, and that the source file’s SHA-256 didn’t change.
  5. It checked the browser made no request to any host other than localhost or 127.0.0.1.
Without REDACT_LIVE_QVAC=1 the test is skipped, not passed.

If something goes wrong

  • QVAC cache is incomplete: a model file is missing; run one analysis in the app with the network on, then try again.
  • Process sandbox did not block remote networking: you ran the script without sandbox-exec; use npm run qvac:offline, not tsx directly.
  • Executable doesn't exist from Playwright: run npx playwright install chromium once.
  • The browser test times out: make sure qvac:service:offline and npm run start are both running, and that nothing else is using port 3101.

8. Check your work

Stop the app and the service, then:
npm test ends with Tests 85 passed (85). Keep offline-receipt.json outside the app, or delete it.

So what just happened?

Redact worked end to end with the operating system refusing every outside connection. Local OCR and Laya proposed, a person decided, and a new flattened copy was saved, all inside the machine. You now have two receipts: one for the analysis, one for the whole app.

Try it yourself

Rename the Laya file in .qvac/models to something without laya in it, run npm run qvac:offline, and read the error. Then rename it back. That’s the cache check protecting you from an offline run that could never work.

Where to next

Next: Check the copy before it’s shared (lesson 11)