Security and sandboxing
On this page
Lesson rewritten November 3, 2026 based on OpenAI’s Computer use guide, its Computer use integration recipes, and Docker and Playwright networking documentation. Additions: OpenAI’s four security rules, its confirmation levels, and the acknowledgment of
pending_safety_checks. Corrections: the previous version cut off the network for a container that needed to call the API, left the proxy without outbound access, acceptednotexample.comin a filter meant to allow onlyexample.com, broke log formatting, and estimated costs using a single hardcoded rate. Sources: Computer use · Integration recipes · Playwright and Docker · Playwright, networking.
Giving an AI control: the risks
Computer Use gives an AI model the ability to act on a computer. That’s powerful, but it can be dangerous. A misconfigured agent could navigate to malicious sites, execute arbitrary code, or expose sensitive data. This isn’t theoretical: a visited page could contain instructions written for the model, and if the environment doesn’t constrain it, the model will follow them as if they were your request. Security isn’t optional—it’s the foundation of any production deployment.
OpenAI’s four rules
OpenAI summarizes Computer Use security in four rules. Apply them in your application and in the runtime environment, not just in the model’s instructions:
- Restrict the environment. Use an isolated browser or virtual machine, an allowlist of sites and actions, and limit access to what the task requires.
- Treat screen content as untrusted. Text from a page, document, or tool output cannot grant permission or replace user instructions. If a page looks like phishing or prompt injection, the agent stops and asks how to proceed.
- Confirm high-stakes actions. Purchases, data transmission, destructive changes, and anything hard to undo remain under user control. Typing sensitive information into a form already counts as transmission.
- Bound and verify execution. Set limits on turns, duration, or cost; provide a way to cancel; and verify the actual outcome, not just the model’s final message.
The starting point is simple: the model’s request to act is not user permission. Your code decides whether to execute, pause for approval, or hand control back. For a batch of actions, stop before the first one that requires confirmation.
Three confirmation levels
OpenAI distinguishes three situations, with examples you can adopt in your policy:
| Level | Examples cited by OpenAI |
|---|---|
| User takes back control | Final step of a password change; bypassing a security barrier, such as an HTTPS warning |
| Confirm at the moment of action | Deleting data, changing permissions or sharing settings, solving a CAPTCHA, installing or running downloaded software, sending or publishing on the user’s behalf, financial transactions, system settings |
| Pre-approval possible in the initial request | Logging into a site the user asked to visit, sending files, moving or renaming files, transmitting sensitive data whose specific use was approved |
Without clear approval in the initial request, confirm just before the action. Don’t ask too early: let the agent proceed on everything that’s safe, then stop at the risk point and explain the action, the risk, and how the data or change will be applied. If the user declines, nothing executes, and your code tells the model what succeeded and what failed.
The principle of least privilege
Your agent should have access only to the minimum required to complete its task. Start with an isolated browser—never the user’s primary browser, which holds open sessions for email, banking, and internal tools. The agent also doesn’t need filesystem access except for a dedicated download folder, doesn’t need internal network access unless a business application requires it, and shouldn’t have administrator rights that could turn a misclick into a system incident. Don’t mount personal files, credentials, or the Docker socket into its environment. Finally, make the session ephemeral: delete data after each run so a workflow never accidentally reuses cookies or cache from the previous one.
Sandboxing with Docker
Two questions arise: where does the browser run, and where do the loop and API key live? OpenAI’s Docker recipe isolates a desktop in a network-less container, controlled from the host via docker exec: only the SDK on the host reads the key. This is the tightest configuration, but it only works for tasks that don’t need the internet. For other tasks, OpenAI requires an environment whose outbound network allows only the task’s sites.
If you keep Playwright and the agent must browse, you can put the loop and browser in the same container, provided three conditions: a non-privileged user, a browser that doesn’t inherit environment variables (so it doesn’t get the key), and outbound network restricted to the task’s sites and the API.
# Playwright image for Python: browsers and system dependencies included
FROM mcr.microsoft.com/playwright/python:v1.64.1-noble
WORKDIR /app
# The playwright package isn't in the image: requirements.txt
# Pin playwright==1.64.1, the same version as the image
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
# Non-privileged user provided by the image
USER pwuser
CMD ["python", "src/main.py"]
Playwright recommends pinning the image version. The image provides the browsers, not the Python library: install the same version of the playwright package. Don’t run the container as root, which disables Chromium’s sandbox. For untrusted sites, Playwright also recommends its seccomp profile, which adds the system calls this sandbox needs to Docker’s default profile.
docker run --rm --init \
--network agent-net \
--security-opt seccomp=seccomp_profile.json \
--ipc=host \
--memory=2g --cpus=1.5 --pids-limit=256 \
-e OPENAI_API_KEY \
computer-use-agent
--init prevents zombie processes, and --ipc=host, recommended by Playwright, prevents Chromium from running out of shared memory and crashing. However, that setting shares the host’s IPC namespace with the container. If you prefer not to open it, increase shared memory with --shm-size instead, as OpenAI’s recipe does for its own container, and test on your heaviest pages. Finally, -e OPENAI_API_KEY without a value inherits the variable from your session: the key doesn’t appear in plaintext on the command line.
On the Python side, launch the browser with the precautions from OpenAI’s recipe—sandbox enabled, empty environment, extensions and filesystem access disabled—and route it through the proxy from the next section, just like the API client:
from openai import DefaultHttpx2Client, OpenAI
from playwright.sync_api import sync_playwright
PROXY = "http://agent-proxy:3128"
# API calls go out through the filtering proxy
client = OpenAI(http_client=DefaultHttpx2Client(proxy=PROXY))
pw = sync_playwright().start()
browser = pw.chromium.launch(
headless=True,
chromium_sandbox=True,
env={}, # the browser inherits no environment variables, so not the key
args=["--disable-extensions", "--disable-file-system"],
proxy={"server": PROXY},
)
context = browser.new_context(
viewport={"width": 1280, "height": 720},
service_workers="block", # see URL filtering below
)
page = context.new_page()
Targeted network restrictions
Place the agent container on an internal Docker network with no external access. A Squid proxy joins that network and becomes its only exit; the proxy is also connected to a regular network. Anything not explicitly allowed doesn’t exist from the agent’s perspective.
# Internal network: no direct external access
docker network create --internal agent-net
# Outbound network, reserved for the proxy
docker network create sortie-proxy
# Squid proxy (image maintained by Canonical) with your allowlist
docker run -d --name agent-proxy --network sortie-proxy \
-v "$PWD/squid.conf:/etc/squid/squid.conf:ro" \
ubuntu/squid:6.6-24.04_beta
# The proxy joins the internal network: this is the agent's only exit
docker network connect agent-net agent-proxy
# squid.conf: allow only the task's domains and the API
http_port 3128
acl autorises dstdomain .example.com api.openai.com
http_access allow autorises
http_access deny all
The key point: the proxy is connected to both networks. If you launched it on the internal network only, it would have no outbound access itself. api.openai.com is on the list because, in this setup, the agent code calls the API from inside the container. The browser doesn’t need it: the URL filter in the next section blocks those calls from the page.
URL filtering
A second, finer barrier lives in the browser itself. By intercepting requests with page.route, compare each URL against the task-specific allowlist—often shorter than the proxy’s—and abort the rest, with a readable log of each block. Watch out for the string-comparison trap OpenAI warns about: https://example.com.evil.test must not pass as https://example.com, and conversely notexample.com ends with example.com. Compare the scheme, the exact host (or a subdomain prefixed with a dot), and the port.
from urllib.parse import urlparse
def setup_url_filter(page, allowed_domains: list[str]):
"""Block requests to unauthorized destinations."""
def autorisee(url: str) -> bool:
parties = urlparse(url)
hote = parties.hostname or ""
return (
parties.scheme == "https"
and parties.port in (None, 443)
and any(hote == d or hote.endswith("." + d) for d in allowed_domains)
)
def check_navigation(route):
if autorisee(route.request.url):
route.continue_()
else:
print(f" BLOQUÉ : {route.request.url}")
route.abort()
page.route("**/*", check_navigation)
# The browser only needs the task's sites: API calls come from your Python code, not the page.
setup_url_filter(page, ["example.com"])
This layer complements the proxy; it doesn’t replace it. Playwright notes that page.route doesn’t intercept requests handled by a service worker, hence service_workers="block" in the context, and WebSockets use a different mechanism (page.route_web_socket). Network filtering remains your primary barrier.
Protecting sensitive data
Masking information in screenshots
Every screenshot goes to the API: anything visible on screen leaves your infrastructure. OpenAI recommends keeping credentials out of page text and captures. If the screen does contain sensitive data, mask it before sending the capture to the model. A black rectangle over known areas is enough, provided you know their coordinates—which is why standardizing viewport size, as covered earlier in this course, helps. Masking doesn’t change the image size: coordinates returned by the model remain valid.
import io
from PIL import Image, ImageDraw
def mask_sensitive_areas(screenshot_bytes: bytes, regions: list) -> bytes:
"""Mask sensitive areas in a PNG capture."""
img = Image.open(io.BytesIO(screenshot_bytes))
draw = ImageDraw.Draw(img)
for x, y, w, h in regions:
draw.rectangle([x, y, x + w, y + h], fill="black")
buffer = io.BytesIO()
img.save(buffer, format="PNG")
return buffer.getvalue()
Knowing what happens to captures
Responses API responses are stored by default: that’s what enables chaining via previous_response_id. With store: false, you must resend elements from previous turns yourself. OpenAI also notes that store: false doesn’t eliminate all forms of retention, and input images have their own exceptions: read its data controls before sending screens that contain personal data.
Never log sensitive data
The same reasoning applies to logs, which often survive much longer than captures. A logging filter rewrites risky patterns before writing: password assignments, OpenAI API keys starting with sk-, and 16-digit sequences that resemble card numbers. The filter acts on the final message, so it also catches what you logged without thinking.
import logging
import re
class SensitiveFilter(logging.Filter):
"""Mask sensitive data before writing logs."""
PATTERNS = [
(re.compile(r"password['\"]?\s*[:=]\s*['\"]?[^\s,}]+", re.IGNORECASE), "password=***"),
(re.compile(r"\bsk-[A-Za-z0-9_-]{20,}"), "sk-***"),
(re.compile(r"\b\d{16}\b"), "****-****-****-****"),
]
def filter(self, record):
msg = record.getMessage()
for pattern, replacement in self.PATTERNS:
msg = pattern.sub(replacement, msg)
record.msg = msg
record.args = None # message is already formatted: don't reformat it
return True
handler = logging.StreamHandler()
handler.addFilter(SensitiveFilter()) # on the handler, it also sees child loggers
logging.basicConfig(level=logging.INFO, handlers=[handler])
Two details make the difference. Setting record.args to None is essential: otherwise, the already-formatted message would be reformatted with its arguments, and any % in a URL would break writing. And a filter on the handler applies to everything it writes, whereas a filter on a logger ignores messages from its child loggers.
API safety checks
Each computer_call carries a pending_safety_checks field: the list of safety checks the API asks you to handle before executing the actions, each with an id, code, and message. OpenAI’s official example stops as soon as it’s not empty. If your application can have a human validate these cases, acknowledge them explicitly by returning them in acknowledged_safety_checks, along with the following screenshot. The model cannot grant this approval on your behalf, and acknowledgment comes before action execution, never after.
def valider_verifications(appel) -> list[dict]:
"""Submit pending checks to an operator; refuse by default."""
if not appel.pending_safety_checks:
return []
for verification in appel.pending_safety_checks:
print(f"Vérification de sécurité [{verification.code}] : {verification.message}")
if input("Autoriser ces actions ? [o/N] ").strip().lower() != "o":
raise RuntimeError("Actions refusées par l'opérateur")
return [
{"id": v.id, "code": v.code, "message": v.message}
for v in appel.pending_safety_checks
]
# In the loop, before executing actions:
acquittees = valider_verifications(appel)
executer_actions(page, appel.actions)
sortie = {
"type": "computer_call_output",
"call_id": appel.call_id,
"output": {
"type": "computer_screenshot",
"image_url": f"data:image/png;base64,{capturer_ecran(page)}",
"detail": "original",
},
}
if acquittees:
sortie["acknowledged_safety_checks"] = acquittees
Execution limits
There’s also the most common risk: an agent that goes off the rails without breaking anything, but runs for two hours and burns your budget. The ExecutionLimits class enforces three ceilings—turns, duration, and estimated cost—and raises an exception as soon as one is reached. Rates aren’t hardcoded: pull them from the model you’re using on OpenAI’s pricing page, since they vary by model.
import time
class ExecutionLimits:
"""Enforce turn, duration, and cost ceilings on the agent."""
def __init__(
self,
prix_entree_par_million: float, # rates for the model in use
prix_sortie_par_million: float,
max_tours: int = 50,
max_duree_s: int = 300,
max_cout_usd: float = 5.0,
):
self.prix_entree = prix_entree_par_million
self.prix_sortie = prix_sortie_par_million
self.max_tours = max_tours
self.max_duree = max_duree_s
self.max_cout = max_cout_usd
self.debut = time.monotonic()
self.tours = 0
self.cout = 0.0
def verifier(self):
"""Call before each request: raise an error if a ceiling is reached."""
if self.tours >= self.max_tours:
raise RuntimeError(f"Limite de tours atteinte ({self.max_tours})")
if time.monotonic() - self.debut >= self.max_duree:
raise RuntimeError(f"Durée maximale dépassée ({self.max_duree} s)")
if self.cout >= self.max_cout:
raise RuntimeError(f"Budget dépassé ({self.cout:.2f} $)")
def compter(self, response):
"""Call after each response: accumulate turns and estimated cost."""
self.tours += 1
self.cout += response.usage.input_tokens / 1_000_000 * self.prix_entree
self.cout += response.usage.output_tokens / 1_000_000 * self.prix_sortie
This is only an estimate: it overstates input tokens served from the prompt cache, which are billed at a reduced rate, and ignores any hosted environment, billed separately. Compare it regularly to your project’s actual usage. Set ceilings per workflow: a 30-second E2E test and an hour-long invoice entry don’t call for the same thresholds.
Production security checklist
Before deploying a Computer Use agent to production, verify:
- Isolated browser or desktop (container or virtual machine), non-privileged user
- Outbound network limited to the task’s sites, and to the API if the loop runs in the same environment
- URL filter that compares scheme, host, and port; service workers blocked
- API key kept out of code and out of the browser’s reach (
env={}) - Screen content treated as untrusted: stop and escalate at the first sign of injection
- Human confirmation before purchases, sends, deletions, and entering sensitive data
pending_safety_checksnever acknowledged without human validation- Turn, duration, and cost limits
- Filtered logs, captures not retained (or encrypted)
- No personal files, credentials, or Docker socket mounted into the environment
- Result verified in the application before announcing success
- Emergency stop mechanism (kill switch)
The last point is the one most often forgotten, yet it’s the only one that helps when all the others have failed. A kill switch is a way to interrupt the agent immediately, without waiting for it to hit one of its limits: a STOP file the loop checks each iteration, a docker stop on the container, or a flag read from a shared variable. Test it before production, not the day you need it.
Key takeaways
- OpenAI’s four rules: restrict the environment, treat the screen as untrusted, confirm high-stakes actions, and bound and verify execution
- The model’s request to act is not permission: your code executes, suspends, or hands control back
- Isolate the browser (non-privileged user, seccomp profile) and allow outbound traffic only to the task’s sites
- Filter URLs by comparing scheme, host, and port—never by a simple string prefix or suffix
- Acknowledge
pending_safety_checksonly after human validation - Enforce turn, duration, and cost limits, and verify the actual outcome