AID-H-019.003: Browser Runtime Admission & Sanitized Observation Export Gate
Pillars: app, infra
Lifecycle: building, operation
Scope boundary
Owns admission of the browser task from current containment and egress receipts and owns the sanitized observation export gate. It does not create the browser context or enforce sandbox network egress.
AID-I-008.001: AID-H-019.003 verifies context-lifecycle evidence before launch and controls observation export; AID-I-008.001 creates and destroys the ephemeral task-scoped browser context and storage partition. The admission gate consumes rather than self-issues context-isolation receipts.
AID-I-001.004: AID-H-019.003 requires and binds a current egress receipt; AID-I-001.004 enforces destination and protocol restrictions at the sandbox network boundary. Receipt verification does not replace packet-path enforcement.
AID-H-019.002: AID-H-019.003 controls whether sanitized browser observations may leave the contained runtime; AID-H-019.002 defines how HTML and active content are demoted into safe untrusted observations. Runtime admission and content transformation are separately evidenced.
Implementation guidance
- AID-H-019.003-G001: Admit a browser task only when current context, sandbox, and egress receipts bind the exact task; export only bounded sanitized observations.
AID-H-019.003-G001
Concept
A browser agent is both a parser and an active execution environment. This control is the fail-closed admission and observation-export gate in front of an externally contained browser process. It does not implement browser cookie or storage partitioning itself; consume the BrowserContext lifecycle from AID-I-008.001. It also does not define the network allowlist itself; bind the task to an egress policy enforced outside the browser process as described in AID-I-001.004. The browser runner should fail closed if it cannot prove the outer sandbox identity, egress policy reference, host-profile isolation, and sanitized observation export path.
Step 1: Define the browser runtime policy
# File: browser_sandbox/policies/support-research.yaml
policy_id: support-research-browser-v1
runtime:
allowed_runtime_classes:
- gvisor
- kata-qemu
- firecracker
- managed-browser-sandbox
max_runtime_seconds: 180
require_outer_sandbox_id: true
filesystem:
readonly_root: true
mount_host_browser_profile: false
mount_host_credential_cache: false
persistent_download_dir: false
browser:
context_lifecycle_ref: AID-I-008.001
persistent_user_profile: false
allow_downloads: false
block_clipboard_access: true
allowed_origins: ["https://docs.example.com"]
network:
egress_policy_ref: AID-I-001.004:support-research-egress-v1
require_external_enforcement: true
observation_export:
allowed_formats: ["text"]
max_text_bytes: 20000
evidence:
record_blocked_requests: true
record_context_lifecycle_ref: true
record_outer_sandbox_id: trueStep 2: Launch Playwright only after proving the outer sandbox contract
# File: browser_sandbox/browser_task_runner.py
from __future__ import annotations
import hashlib
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from urllib.parse import urlparse
from playwright.async_api import Browser, BrowserContext, Route, async_playwright
from browser.ephemeral_context import create_task_context # AID-I-008.001
from agent.content_sanitizer import sanitize_html_content # AID-H-019.002
@dataclass
class BrowserEvidence:
task_id: str
policy_id: str
outer_sandbox_id: str
runtime_class: str
egress_policy_ref: str
blocked_urls: list[str]
final_url: str | None
observation_path: str
context_lifecycle_ref: str
policy_sha256: str
policy_signature_sha256: str
runtime_attestation_sha256: str
teardown_receipt_sha256: str
observation_sha256: str
runtime_limit_seconds: int
readonly_root: bool
external_egress_enforced: bool
@dataclass(frozen=True)
class VerifiedBrowserPolicy:
policy: dict
policy_sha256: str
signature_sha256: str
@dataclass(frozen=True)
class RuntimeSandboxReceipt:
task_id: str
policy_sha256: str
outer_sandbox_id: str
runtime_class: str
egress_policy_ref: str
attestation_sha256: str
runtime_limit_seconds: int
readonly_root: bool
host_browser_profile_mounted: bool
host_credential_cache_mounted: bool
persistent_download_dir: bool
external_egress_enforced: bool
@dataclass(frozen=True)
class BrowserPolicyLoader:
load_verified_yaml_fn: Callable[[str], VerifiedBrowserPolicy]
def load_verified_yaml(self, path: str) -> VerifiedBrowserPolicy:
result = self.load_verified_yaml_fn(path)
if not isinstance(result, VerifiedBrowserPolicy):
raise RuntimeError("browser policy adapter returned an unverified value")
if not result.policy or not re.fullmatch(r"[0-9a-f]{64}", result.policy_sha256) or not re.fullmatch(r"[0-9a-f]{64}", result.signature_sha256):
raise RuntimeError("browser policy verification receipt is invalid")
return result
@dataclass(frozen=True)
class RuntimeAttestor:
# These adapters authenticate platform-issued receipt bytes and current state.
attest_current_process_fn: Callable[[str, str], RuntimeSandboxReceipt]
confirm_task_teardown_fn: Callable[[str, str], str]
def attest_current_process(
self, task_id: str, policy_sha256: str
) -> RuntimeSandboxReceipt:
result = self.attest_current_process_fn(task_id, policy_sha256)
if not isinstance(result, RuntimeSandboxReceipt):
raise RuntimeError("runtime attestor returned an invalid receipt")
if (
result.task_id != task_id
or result.policy_sha256 != policy_sha256
or not re.fullmatch(r"[0-9a-f]{64}", result.attestation_sha256)
):
raise RuntimeError("authenticated runtime receipt is misbound")
return result
def confirm_task_teardown(self, task_id: str, outer_sandbox_id: str) -> str:
digest = self.confirm_task_teardown_fn(task_id, outer_sandbox_id)
if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
raise RuntimeError("authenticated teardown receipt is invalid")
return digest
def write_new_evidence_file(path: Path, payload: bytes) -> None:
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(path, flags, 0o600)
try:
remaining = memoryview(payload)
while remaining:
written = os.write(descriptor, remaining)
if written <= 0:
raise OSError("short write while persisting browser evidence")
remaining = remaining[written:]
os.fsync(descriptor)
finally:
os.close(descriptor)
def normalize_origin(url: str) -> str:
parts = urlparse(url)
if parts.scheme not in {"http", "https"} or not parts.netloc:
raise ValueError(f"invalid origin: {url}")
return f"{parts.scheme}://{parts.netloc.lower()}"
def assert_outer_sandbox(policy: dict, receipt: RuntimeSandboxReceipt) -> None:
if not isinstance(receipt, RuntimeSandboxReceipt):
raise PermissionError("verified runtime attestation is required")
allowed_runtime_classes = set(policy["runtime"]["allowed_runtime_classes"])
if receipt.runtime_class not in allowed_runtime_classes:
raise PermissionError(f"runtime class {receipt.runtime_class!r} is not approved")
if policy["runtime"].get("require_outer_sandbox_id") and not receipt.outer_sandbox_id:
raise PermissionError("missing outer sandbox identity")
expected_egress_ref = policy["network"]["egress_policy_ref"]
if receipt.egress_policy_ref != expected_egress_ref:
raise PermissionError("browser task is not bound to the expected egress policy")
if not re.fullmatch(r"[0-9a-f]{64}", receipt.attestation_sha256):
raise PermissionError("runtime attestation digest is invalid")
expected_runtime_limit = policy["runtime"]["max_runtime_seconds"]
if (
type(receipt.runtime_limit_seconds) is not int
or receipt.runtime_limit_seconds <= 0
or receipt.runtime_limit_seconds > expected_runtime_limit
):
raise PermissionError("outer sandbox runtime limit is missing or too permissive")
if policy["filesystem"].get("readonly_root") and receipt.readonly_root is not True:
raise PermissionError("outer sandbox root filesystem is not read-only")
if (
not policy["filesystem"].get("mount_host_browser_profile")
and receipt.host_browser_profile_mounted is not False
):
raise PermissionError("host browser profile mount detected")
if (
not policy["filesystem"].get("mount_host_credential_cache")
and receipt.host_credential_cache_mounted is not False
):
raise PermissionError("host credential cache mount detected")
if (
not policy["filesystem"].get("persistent_download_dir")
and receipt.persistent_download_dir is not False
):
raise PermissionError("persistent download directory detected")
if (
policy["network"].get("require_external_enforcement")
and receipt.external_egress_enforced is not True
):
raise PermissionError("external egress enforcement is not attested")
def origin_allowed_by_renderer_policy(url: str, allowed_origins: set[str]) -> bool:
# Defense in depth only: the external AID-I-001.004 egress boundary remains
# authoritative for DNS/IP decisions. This is not an H-019.001 receipt.
try:
origin = normalize_origin(url)
except ValueError:
return False
return origin in allowed_origins
async def install_route_policy(
context: BrowserContext,
blocked_urls: list[str],
allowed_origins: set[str],
) -> None:
async def route_handler(route: Route) -> None:
request_url = route.request.url
if origin_allowed_by_renderer_policy(request_url, allowed_origins):
await route.continue_()
return
blocked_urls.append(request_url)
await route.abort("blockedbyclient")
await context.route("**/*", route_handler)
async def run_browser_task(
*,
task_id: str,
policy_path: str,
start_url: str,
policy_loader: BrowserPolicyLoader,
runtime_attestor: RuntimeAttestor,
observation_dir: Path,
) -> BrowserEvidence:
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", task_id):
raise ValueError("invalid task_id")
verified_policy = policy_loader.load_verified_yaml(policy_path)
if not isinstance(verified_policy, VerifiedBrowserPolicy):
raise PermissionError("verified browser policy is required")
if not re.fullmatch(r"[0-9a-f]{64}", verified_policy.policy_sha256):
raise PermissionError("browser policy digest is invalid")
if not re.fullmatch(r"[0-9a-f]{64}", verified_policy.signature_sha256):
raise PermissionError("browser policy signature digest is invalid")
policy = verified_policy.policy
policy_id = policy["policy_id"]
runtime_receipt = runtime_attestor.attest_current_process(
task_id, verified_policy.policy_sha256
)
assert_outer_sandbox(policy, runtime_receipt)
allowed_origins = {
normalize_origin(origin) for origin in policy["browser"]["allowed_origins"]
}
if not allowed_origins or not origin_allowed_by_renderer_policy(start_url, allowed_origins):
raise PermissionError("start_url_not_allowed_by_renderer_policy")
blocked_urls: list[str] = []
final_url: str | None = None
observation_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
if observation_dir.is_symlink() or not observation_dir.is_dir():
raise PermissionError("observation directory must be a real directory")
observation_path = observation_dir / f"{task_id}.txt"
teardown_receipt_sha256: str | None = None
async with async_playwright() as pw:
browser: Browser = await pw.chromium.launch(
headless=True,
args=[
"--disable-dev-shm-usage",
"--disable-extensions",
"--disable-sync",
"--no-first-run",
],
)
context: BrowserContext | None = None
try:
context = await create_task_context(
browser,
task_id=task_id,
trust_zone="browser-safe-fetch",
accept_downloads=False,
permissions=[],
)
# AID-I-008.001 must return a fresh context with service workers
# blocked; otherwise a Page-only route hook is bypassable.
if context.pages:
raise RuntimeError("ephemeral context was not empty at handoff")
await install_route_policy(context, blocked_urls, allowed_origins)
page = await context.new_page()
await page.goto(start_url, wait_until="domcontentloaded", timeout=20_000)
final_url = page.url
# H-019.002 owns active-content removal and the untrusted-data label.
rendered_html = (await page.content()).encode("utf-8")
max_bytes = policy["observation_export"]["max_text_bytes"]
if type(max_bytes) is not int or max_bytes <= 0 or max_bytes > 10_000_000:
raise ValueError("observation byte bound is invalid")
record = sanitize_html_content(
rendered_html,
source_ref=f"browser-task:{task_id}",
max_input_bytes=max(max_bytes * 20, max_bytes),
max_output_bytes=max_bytes,
sanitizer_policy_version=policy_id,
)
observation_bytes = (
json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n"
).encode("utf-8")
observation_sha256 = hashlib.sha256(observation_bytes).hexdigest()
write_new_evidence_file(observation_path, observation_bytes)
await context.close()
context = None
teardown_receipt_sha256 = runtime_attestor.confirm_task_teardown(
task_id, runtime_receipt.outer_sandbox_id
)
finally:
if context is not None:
await context.close()
await browser.close()
if teardown_receipt_sha256 is None:
raise RuntimeError("browser task teardown was not independently confirmed")
return BrowserEvidence(
task_id=task_id,
policy_id=policy_id,
outer_sandbox_id=runtime_receipt.outer_sandbox_id,
runtime_class=runtime_receipt.runtime_class,
egress_policy_ref=runtime_receipt.egress_policy_ref,
blocked_urls=blocked_urls,
final_url=final_url,
observation_path=str(observation_path),
context_lifecycle_ref=policy["browser"]["context_lifecycle_ref"],
policy_sha256=verified_policy.policy_sha256,
policy_signature_sha256=verified_policy.signature_sha256,
runtime_attestation_sha256=runtime_receipt.attestation_sha256,
teardown_receipt_sha256=teardown_receipt_sha256,
observation_sha256=observation_sha256,
runtime_limit_seconds=runtime_receipt.runtime_limit_seconds,
readonly_root=runtime_receipt.readonly_root,
external_egress_enforced=runtime_receipt.external_egress_enforced,
)Step 3: Verify the outer sandbox contract
Run this runner only from the approved sandbox launcher. The BrowserPolicyLoader verifies exact policy bytes, and the RuntimeAttestor authenticates platform-issued launch and teardown receipts bound to the task, policy digest, and sandbox identity; caller-provided booleans, dataclass construction, and environment variables are not proof. Negative tests must fail on a missing or invalid policy receipt, runtime-class or egress-policy mismatch, unapproved origins, downloads, host-profile mounts, and page attempts to reach metadata, loopback, private IP, or disallowed SaaS destinations. Storage residue tests belong to AID-I-008.001; egress bypass tests belong to AID-I-001.004.
Action: Treat the browser process as an isolated tool invocation. Keep evidence for the policy and signature digests, runtime-attestation digest, outer sandbox ID, runtime class, egress policy reference, complete blocked-request population, sanitized observation digest, and authenticated AID-I-008.001 context teardown receipt for every browser-agent task. Do not emit PASS until context closure and teardown readback succeed.
Open-source tools
- Playwright
- Chromium
- gVisor
- Kata Containers
- Firecracker
- Envoy
- Cilium
- mitmproxy
- Cloudflare Sandbox SDK (Apache-2.0 SDK; requires the managed Cloudflare Workers and Containers runtime; egress and host isolation remain external)
Source-available / open-weight tools
- Browserless self-hosted image (browser orchestration service; no sandbox boundary) (SSPL-1.0; source-available)
Commercial / hosted tools
- Browserless Cloud (browser orchestration only; independent sandbox and egress controls required)
- Browserless Enterprise Docker (browser orchestration only; independent sandbox and egress controls required)
- Menlo Agent Runtime Security (MARS)
- Browserbase (browser orchestration only; independent sandbox and egress controls required)
Threat mappings
MITRE ATLAS
- AML.T0051.001 LLM Prompt Injection: Indirect
- AML.T0078 Drive-by Compromise
- AML.T0113 Steal Web Session Cookie
- AML.T0011.003 User Execution: Malicious Link
- AML.T0100 AI Agent Clickbait
- AML.T0108 AI Agent (C2)
MAESTRO
- Agent Tool Misuse (L7)
- Lateral Movement (Cross-Layer)
- Data Exfiltration (L2) (browser egress control blocks web-mediated exfiltration)
OWASP LLM Top 10 2026
- LLM01:2026 Prompt Injection
OWASP ML Top 10 2023
- N/A
OWASP Top 10 for Agentic Applications 2026
- ASI01:2026 Agent Goal Hijack
- ASI02:2026 Tool Misuse and Exploitation
- ASI05:2026 Unexpected Code Execution (RCE)
- ASI06:2026 Memory & Context Poisoning
NIST Adversarial Machine Learning 2025
- NISTAML.015 Indirect Prompt Injection
- NISTAML.039 Compromising connected resources
Cisco Integrated AI Security and Safety Framework
- AISubtech-12.1.3 Unsafe System / Browser / File Execution
- AITech-1.2 Indirect Prompt Injection
- AITech-12.1 Tool Exploitation
- AITech-4.2 Context Boundary Attacks
Google Secure AI Framework 2.0 - Risks
- PIJ: Prompt Injection
- IIC: Insecure Integrated Component
- RA: Rogue Actions
- SDD: Sensitive Data Disclosure
Databricks AI Security Framework 3.0
- Agents - Core 13.11: Unexpected RCE and Code Attacks
- Agents - Core 13.2: Tool Misuse
- Model Serving - Inference requests 9.1: Prompt inject