AID-H-019.003: Browser Runtime Admission & Sanitized Observation Export Gate

Version 1.20261015 · Actionable sub-technique

Before browser launch, require current asset-bound receipts proving browser-context isolation, outer sandbox identity, host-profile isolation, download policy, and egress boundary. Bind receipts to the exact task and browser build, then release only bounded sanitized observations.

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.
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: true
Step 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

Source-available / open-weight tools

Commercial / hosted tools

Threat mappings

MITRE ATLAS

MAESTRO

OWASP LLM Top 10 2026

OWASP ML Top 10 2023

OWASP Top 10 for Agentic Applications 2026

NIST Adversarial Machine Learning 2025

Cisco Integrated AI Security and Safety Framework

Google Secure AI Framework 2.0 - Risks

Databricks AI Security Framework 3.0

AID-H-019.003: Browser Runtime Admission & Sanitized Observation Export Gate | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version