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…

Synced from Prax at 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:

  1. 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.
  2. 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.
  3. The person picks Allow once, Allow for 1 hour (that tool, for Prax, for an hour; every use still recorded) or Deny.
  4. 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, Authorization or X-Agent-Signature also 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 /workspace is the user’s data. Taint is set before the command runs, since a cat … | 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_shell call 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/upload was held, shown as “Prax wants to POST example.org/upload”, denied, and blocked (403);
    • a GET to 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_shell and run_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.

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).