Containment outside the agent: approvals, secrets, the browser, egress
Principle. An agent that reads untrusted content can be talked into things. So the checks that stop it must not run through the agent. A person's consent has to reach the enforcement point without passing through the model. Secrets must…
f7a8cb56
View source ↗
Principle. An agent that reads untrusted content can be talked into things. So the checks that stop it must not run through the agent. A person’s consent has to reach the enforcement point without passing through the model. Secrets must be usable without the model ever holding them. And what leaves the box must be decided by something the model cannot argue with.
Four opt-in mechanisms implement that. All default off, per the flag rule. Recommended configuration:
# prax/.env
OUT_OF_BAND_APPROVALS_ENABLED=true
BROWSER_SECRETS_OUT_OF_CONTEXT=true
BROWSER_PAUSE_FOR_USER=true
EGRESS_GATE_URL=http://127.0.0.1:8790
EGRESS_GATE_TOKEN=<the gate's EGRESS_ADMIN_TOKEN>
# deploy: SANDBOX_EGRESS=1 EGRESS_ADMIN_TOKEN=<same token> (and SANDBOX_LIMITS=1)
They need TeamWork with approval support (/api/external/approvals,
/api/approvals) and prax-sandbox with docker-compose.egress.yml.
TeamWork must authenticate its UI, because approvals are decided only by
an authenticated person. Set INTERNAL_API_KEY in TeamWork’s .env and log in
once in the browser (the session lasts 30 days), or run behind the
authenticating proxy. Without either, TeamWork refuses every decision,
and Prax therefore refuses every action that needs one. That is fail-closed on
purpose: an unauthenticated route cannot tell your browser from a program.
1. Out-of-band approvals — OUT_OF_BAND_APPROVALS_ENABLED
Before. The HIGH-risk gate and the lethal-trifecta gate in
prax/agent/governed_tool.py blocked the first call and told the model:
“confirm with the user, then call again with the same arguments”. The second
call ran. Nothing checked that a person had been asked, so the model could
confirm itself. That was July review #8. An injected model is exactly the
party that would.
Now:
- The call creates an approval in TeamWork. It is bound to the tool and a SHA-256 of its full arguments, and carries a readable summary.
- The call waits, heartbeating so the run is not abandoned as idle, while TeamWork shows the person a dialog. The dialog sits in the UI, not in the chat.
- The person picks Allow once, Allow for 1 hour (that tool, for Prax, for an hour; every use still recorded) or Deny.
- Prax reads the decision from TeamWork with its own credential, spends the approval once, and runs the call. Anything else — denied, unanswered, TeamWork unreachable, request vanished — is a refusal.
One approval covers both gates for the same call. prax/agent/human_approval.py
drives the gates; prax/services/approval_service.py holds the shared core.
Why the model cannot approve itself.
- The model never carries the decision. Prax reads it from TeamWork, not from anything the model says.
- TeamWork’s decision route requires a person. It needs a logged-in UI
session cookie. The UI key header alone is refused, since that is the
scripts path. Any request with
X-API-Key,AuthorizationorX-Agent-Signaturealso gets a 403. - The external API cannot decide either. An agent can only ask, check and spend its own requests.
- No model tool can reach the decision route. Prax’s fetch tools block loopback, and the sandbox cannot reach the host.
How strong it is. An approval is exactly as strong as your UI login: the
INTERNAL_API_KEY session cookie, or the proxy’s identity. The operator can
choose APPROVALS_ALLOW_UNAUTHENTICATED=true in TeamWork to skip that. It is
off by default, because it lets any program that can reach the port approve.
Verified end to end (2026-09-24) against a live TeamWork:
- An approved call ran about 2 s after the click.
- A denied call was refused.
- Retrying the denied call asked the person again, and was refused when nobody answered.
- Only the approved call executed.
1b. Hard floors and unattended runs
Hard floors (HARD_FLOORS_ENABLED; prax/agent/hard_floors.py; pattern
credit: OpenWorker, Andrew Ng et
al.). Logging in or revealing stored credentials, installing or activating
code that runs with Prax’s authority, and starting a billable GPU run only
happen on a person’s decision about that exact call. That is an approval here
that a timed grant did not give (“allow for 1 hour” never covers a floor),
or, with approvals off, the user’s own message naming the action and its
target. Floors are checked before earned trust, smart auto-approve, the
turn-wide latch and the spoke enforce switch, so none of those can lower one.
HARD_FLOOR_EXTRA_TOOLS adds floors; the built-ins can’t be removed.
Parked approvals (PARKED_APPROVALS_ENABLED, needs
OUT_OF_BAND_APPROVALS_ENABLED; prax/services/parked_approvals.py; same
credit). A scheduled or task-runner turn has nobody watching, so instead of
waiting APPROVAL_WAIT_SECONDS and losing the work it parks the request
(answerable for PARKED_APPROVAL_HOURS, default 24 — TeamWork’s
expires_in_seconds) and ends saying it is waiting. When someone approves,
the task re-runs and spends that approval on that exact action; a refusal or
expiry is reported to the user as “not done”. A re-run that reaches a
different action asks again, up to PARKED_MAX_RESUMES times per task.
Parked requests are kept on disk, so a restart doesn’t lose them.
2. Secrets stay out of the model — BROWSER_SECRETS_OUT_OF_CONTEXT
browser_login returned the stored site password (SITES_CREDENTIALS_PATH)
into the model’s context, and so into the trace. With the flag it is replaced
by browser_fill_login(domain, username_selector, password_selector). That
tool types the stored values into the page and reports only which fields it
filled. browser_credentials never showed the password.
Stored credentials go only to their own site. The open page must be HTTPS
on the credential’s domain, an alias of it, or a subdomain of either. This is
checked before the username and again before the password, because the
page may move. Without the check, an injected page could get the model to
“log in to github.com” while the browser shows attacker.example, and the
page would read the password.
The other login route, browser_request_login, already has this shape: the
person logs in over VNC, and the agent never sees anything.
Model-provider keys are kept out of Prax entirely by the secrets proxy; see secrets-proxy.md.
3. The agent yields the browser — BROWSER_PAUSE_FOR_USER
While a person drives the shared browser, every browser action stands down with “Paused: the user is controlling the browser”. That covers Playwright navigate, click, fill, press and fill-login, and the sandbox CDP tools. A person counts as driving when:
- a VNC login is in progress, or
- TeamWork reports Take control (the browser panel toggle), or any input through the panel in the last 30 s.
If TeamWork is unreachable, the agent proceeds. This is a lock on shared input, not a security boundary, and it must not take the browser down with TeamWork.
4. The egress gate — EGRESS_GATE_URL + prax-sandbox docker-compose.egress.yml
The sandbox moves onto an internal Docker network with no route anywhere. Its only exit is prax-sandbox’s egress gate, a policy proxy (see that repo’s README). The gate:
- allows, denies, or holds each destination;
- resolves names itself and refuses private, loopback and link-local addresses (SSRF);
- logs every decision.
When the policy says ask, TeamWork relays the question to a person in
the same dialog (“The sandbox wants to connect to example.net:443”) and posts
the answer back to the gate (teamwork/src/teamwork/services/gate_relay.py,
EGRESS_GATES). Allow for 1 hour works here too.
Why TeamWork and not Prax relays. Whoever holds a gate’s admin token can approve anything and clear taint. Prax is the party being judged, so it holds only each gate’s raise-only taint token: it can make the gates stricter, never looser. A code review of the first version (where Prax relayed) caught exactly that.
Taint. Prax marks the gate tainted when a turn:
- has read private data (the trifecta’s private leg), or
- is about to run code in the sandbox, whose
/workspaceis the user’s data. Taint is set before the command runs, since acat … | curl …is one call.
While tainted, the policy’s clean_only destinations are asked about rather
than allowed. Taint is raise-only from Prax’s side. It is re-asserted while
a tainted turn is live, and lapses at the gate about 5 minutes after the last
one ends. Prax cannot clear it early, so a compromised Prax cannot either.
Taint does not fail open:
- Updates are sent synchronously, one at a time.
- Each turn’s taint is a lease, so a turn that never cleans up cannot pin it.
- Prax re-asserts taint every minute, well inside the gate’s 5-minute TTL.
TeamWork withdraws a question from the dialog once the gate has stopped waiting for it.
Verified end to end (2026-09-24) against the real sandbox image, gate and TeamWork:
- An asked destination returned 200 after a person allowed it.
- Another was blocked after a person denied it.
- A governed
sandbox_shellcall tainted the gate, and the end of the turn cleared it.
5. Prax’s own traffic — PRAX_EGRESS_GATE_URL + the forward proxy’s egress policy
The sandbox gate covers code the agent runs. Prax’s own process holds your memory, workspace and conversations, and its fetch tools can put data in a URL. So its traffic needs the same treatment. It comes in two parts.
The policy. prax-secrets-proxy’s forward proxy (PROXY_EGRESS_POLICY)
already terminates TLS for all of Prax’s egress, to inject keys. With a
policy it also decides each request:
- It judges host, method and path, for HTTPS too. Reading a page can be allowed while posting to that same site is asked about.
- It asks through the same TeamWork dialog, relayed by TeamWork: “The agent wants to POST example.org/upload”.
- It never resolves a name before deciding.
The example policy:
- allows the providers Prax needs;
- allows reading the web while clean;
- asks before any write to a site that is not listed.
The enforcement. deploy/systemd/prax.service.d/40-egress-only-through-the-proxy.conf
restricts the Prax process to loopback (IPAddressDeny=any,
IPAddressAllow=localhost), and the kernel enforces it on the unit’s cgroup.
Prax cannot open a connection anywhere except through the proxy, whatever the
model tells it. Discord needs DISCORD_USE_PROXY=true, because discord.py
ignores proxy variables.
Verified live (2026-09-24):
- A process under that restriction could not reach example.net directly, but could through the proxy.
- With Prax’s real poller and a logged-in person in TeamWork:
POST example.org/uploadwas held, shown as “Prax wants to POST example.org/upload”, denied, and blocked (403);- a
GETto an unknown site was held, allowed, and returned 200.
Residuals:
- DNS. Prax’s resolver (127.0.0.53) is loopback, so names Prax itself resolves (its own SSRF checks do) can still leave as queries.
- Taint for Prax’s own process is incomplete. It counts memory and workspace tool reads, but not the conversation Prax is always holding. Treat Prax’s own traffic as permanently holding private data when you write its policy.
6. Prax without the Docker socket — SANDBOX_DAEMON_URL + deploy/systemd/prax-sandbox-daemon.service
The Docker socket is root-equivalent over its containers. Whoever holds it can start a container with no egress gate, no limits and any mount. A compromised Prax holding it could therefore walk around every boundary above.
prax-sandbox’s control daemon holds Docker instead, and exposes only sandbox
operations over a token-guarded loopback API. Image rebuilds stay off. Prax
drives the sandbox through it (SANDBOX_DAEMON_URL / SANDBOX_DAEMON_TOKEN),
and prax.service.d/50-no-docker.conf makes the Docker sockets inaccessible
to the Prax process.
Over the daemon, Prax cannot ask Docker what the sandbox mounts, so set
SANDBOX_WORKSPACE_MOUNT_SOURCE.
Verified (2026-09-24) with the Prax process’s Docker access deliberately dead:
- health,
run_shellandrun_command(host paths translated) all worked through the daemon; - the exec deadline was enforced there.
This is the part of “the harness in its own cell” that matters for containment. Prax already runs under systemd file confinement, and with sections 5 and 6 it has no route to the network except the proxy, and no route to Docker except the daemon.
7. An independent injection classifier — built, measured, not recommended (yet)
sidecars/injection-screen is a classifier that runs outside Prax.
INJECTION_SCREEN_URL wires it into the point where untrusted tool results
are labelled; flagged content is either labelled or blocked. It is off,
and with the default open model it should stay off.
Measured with scripts/eval_injection_screen.py, at threshold 0.95:
- 5% of injections planted inside real pages were caught — the case that matters for Prax;
- 32% of direct injections were caught;
- 25% of popular READMEs were falsely flagged;
- 0% of Wikipedia articles were falsely flagged.
Muse’s ensemble is trained on Meta’s own red-teaming; an off-the-shelf model
is not that. The slot and the eval are here, so that a better model can be
dropped in and measured, not trusted. See sidecars/injection-screen/README.md.
Compared with Meta’s Muse (see research note)
| Muse | Prax + TeamWork + prax-sandbox, with the flags on |
|---|---|
| Approvals in the client UI, straight to Sentinel, never via chat | Same shape. TeamWork dialog → decision read by governance; agent credentials refused on the decision route |
| Scopes: one-time, session, task, time-bounded, perpetual | One-time, 1 hour, 1 day; revocable. No task-scoped or perpetual grants (perpetual deliberately omitted) |
| Execution stops while asked | Yes: the call blocks until decided or APPROVAL_WAIT_SECONDS |
| Sentinel: sole authority for all egress, L4 + L7, SSRF after DNS | Sandbox: sole exit; L4 for HTTPS, L7 for plain HTTP; SSRF after DNS, with the checked IP pinned. Prax’s own process: sole exit, with the kernel enforcing loopback-only; L7 including HTTPS through the TLS-terminating proxy; SSRF after DNS. DNS itself is not gated for the Prax process |
| Kernel-level per-process taint | Per-container, per-turn taint. Coarser |
| Surrogates for all credentials, including site passwords | Provider keys via the secrets proxy; site passwords filled without entering context. User OAuth tokens: Prax has no OAuth connector store to surrogate |
| Agent paused while the user drives the browser | Yes (flag) |
| Harness itself inside the isolated cell | Not as a container, but boxed on every axis that matters: systemd file confinement; network only through the policy proxy (kernel-enforced); no Docker socket, with the sandbox reached through the daemon |
| Injection classifiers outside the cell | Built as a sidecar slot, not enabled: the default open model catches 5% of injections planted in pages and flags 25% of READMEs (measured) |
The honest claim:
- Prax matches Muse’s consent model: out-of-band, scoped, cannot be approved by the agent.
- It matches Muse’s credential principle for provider keys and site passwords.
- It matches Muse’s egress model for both the sandbox and Prax’s own process. Prax’s process is judged on full HTTPS requests; the sandbox is judged per host.
- It does not match per-process taint (Meta built custom kernel hooks for that).