AID-I-008.001: Ephemeral Browser Context Lifecycle & Storage Partitioning
Pillars: app, infra, data
Lifecycle: building, operation
Scope boundary
Owns per-task or per-trust-zone browser-context creation, browser-managed cookie and storage partitioning, context rotation, and deterministic context teardown. It does not create the outer browser process sandbox or isolate non-browser agent memory.
AID-H-019.003: AID-I-008.001 owns BrowserContext and browser-managed state lifecycle inside the runtime; AID-H-019.003 owns admission and launch of the outer browser runtime plus sanitized observation export. Process isolation, blocked host profiles, and enforced egress do not prove per-task cookie or storage rotation, while a new BrowserContext does not establish the outer sandbox boundary.
AID-H-017.004: AID-I-008.001 makes browser-profile state ephemeral per task or trust zone; AID-H-017.004 makes agent short-term prompt and session state transient and isolated. The same lifecycle principle applies to different state owners, and neither control's cleanup receipt satisfies the other.
AID-I-004.001: AID-I-008.001 isolates browser-engine state such as cookies, localStorage, IndexedDB, cache, and history; AID-I-004.001 isolates volatile model and agent working context. Browser storage is not conversational context, and clearing one does not establish cleanup of the other.
Implementation guidance
- AID-I-008.001-G001: Create a fresh browser context for every agent task or trust-zone transition, bind it to a task ID, and destroy it deterministically on task completion with residue verification.
- AID-I-008.001-G002: Define trust-set boundaries for origins and enforce state clearing or context rotation whenever a task crosses from one trust set to another.
AID-I-008.001-G001
Lifecycle scope and owner
This method applies when browser automation can use a non-persistent Playwright BrowserContext. It never attaches to a persistent user-data directory or daily browser profile. A task controller outside model control owns context creation, page population, teardown, signed evidence, and independent replay. Downloads remain disabled here; a workflow that needs downloads must use the separate AID-I-008.003 quarantine path.
Task-scoped context registry
// File: browser/task_context_registry.js
import { randomUUID } from 'node:crypto';
export class TaskContextRegistry {
constructor({browser, signedPolicy, evidenceWriter, evidenceVerifier}) {
if (
!signedPolicy || signedPolicy.schema_version
!== 'aidefend.browser-context-policy.v1'
|| !signedPolicy.policy_sha256
|| !Number.isInteger(signedPolicy.close_timeout_ms)
) throw new Error('verified signed browser-context policy required');
this.browser = browser;
this.policy = signedPolicy;
this.writer = evidenceWriter;
this.verifier = evidenceVerifier;
this.entries = new Map();
}
async create({taskId, trustZone}) {
if (!taskId || !trustZone) throw new TypeError('verified task scope required');
const contextId = randomUUID();
const context = await this.browser.newContext({
storageState: undefined,
serviceWorkers: 'block',
permissions: [],
acceptDownloads: false
});
const pages = new Set();
const pageIds = new WeakMap();
context.on('page', (page) => {
pages.add(page);
pageIds.set(page, randomUUID());
// Keep every created page in the lifecycle population after closure.
page.on('close', () => { page.closedAt = new Date().toISOString(); });
page.on('download', (download) => {
void download.cancel();
});
});
const entry = {
context, contextId, taskId, trustZone, pages, pageIds,
createdAt: new Date().toISOString(), closing: false
};
this.entries.set(contextId, entry);
let receipt;
try {
receipt = await this.writer.appendAndVerify({
schema_version: 'aidefend.browser-context-event.v1',
event: 'context_created',
task_id: taskId,
trust_zone: trustZone,
context_id: contextId,
browser_version: this.browser.version(),
policy_sha256: this.policy.policy_sha256,
created_at: entry.createdAt
});
} catch (evidenceError) {
let destructionError = null;
try {
await context.close({reason: 'creation evidence unavailable'});
} catch (error) {
destructionError = error;
}
const stillRegistered = this.browser.contexts().includes(context);
if (stillRegistered || destructionError) {
entry.cleanupPending = true;
entry.closing = false;
throw new AggregateError(
[evidenceError, destructionError].filter(Boolean),
'creation evidence failed and context destruction is unresolved'
);
}
this.entries.delete(contextId);
throw evidenceError;
}
entry.createReceiptSha256 = receipt.receipt_sha256;
return {contextId, context};
}
async close(contextId, reason) {
const entry = this.entries.get(contextId);
if (!entry || entry.closing) throw new Error('unknown or closing context');
entry.closing = true;
const pagePopulation = Array.from(entry.pages, (page) => ({
page_id: entry.pageIds.get(page),
url_sha256: this.writer.digest(page.url())
})).sort((left, right) => left.page_id.localeCompare(right.page_id));
let closeError = null;
try {
await Promise.race([
entry.context.close({reason}),
new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error('context close policy timeout')),
this.policy.close_timeout_ms
);
timer.unref();
})
]);
} catch (error) {
closeError = error;
}
const stillRegistered = this.browser.contexts().includes(entry.context);
let closeReceipt;
try {
closeReceipt = await this.writer.appendAndVerify({
schema_version: 'aidefend.browser-context-event.v1',
event: 'context_closed',
task_id: entry.taskId,
trust_zone: entry.trustZone,
context_id: contextId,
policy_sha256: this.policy.policy_sha256,
create_receipt_sha256: entry.createReceiptSha256,
page_population: pagePopulation,
page_population_count: pagePopulation.length,
browser_context_still_registered: stillRegistered,
outcome: closeError || stillRegistered ? 'ERROR' : 'PASS',
error_class: closeError ? closeError.constructor.name : null,
closed_at: new Date().toISOString()
});
} catch (evidenceError) {
entry.cleanupPending = true;
entry.closing = false;
throw evidenceError;
}
if (closeError || stillRegistered) {
entry.cleanupPending = true;
entry.closing = false;
throw closeError || new Error('browser still reports closed context');
}
const replay = await this.verifier.replay({
control: 'AID-I-008.001',
task_id: entry.taskId,
context_id: contextId,
policy_sha256: this.policy.policy_sha256,
create_receipt_sha256: entry.createReceiptSha256,
close_receipt_sha256: closeReceipt.receipt_sha256,
expected_page_population_count: pagePopulation.length
});
if (
replay.outcome !== 'PASS'
|| replay.context_id !== contextId
|| replay.verified_page_population_count !== pagePopulation.length
) {
entry.cleanupPending = true;
entry.closing = false;
throw new Error('independent context-lifecycle replay failed');
}
this.entries.delete(contextId);
return replay;
}
async run({taskId, trustZone}, taskFunction) {
const created = await this.create({taskId, trustZone});
try {
const page = await created.context.newPage();
return await taskFunction(page, created.context);
} finally {
await this.close(created.contextId, 'task completed');
}
}
}Deterministic cross-task isolation test
// File: test/browser_context_isolation.test.js
import assert from 'node:assert/strict';
const fixture = process.env.LOCAL_BROWSER_FIXTURE_ORIGIN;
if (!fixture || new URL(fixture).protocol !== 'https:') {
throw new Error('local HTTPS fixture origin required');
}
await registry.run(
{taskId: 'isolation-fixture-a', trustZone: 'test'},
async (page, context) => {
await page.goto(fixture + '/blank');
await context.addCookies([{
name: 'context_probe',
value: 'task-a',
url: fixture,
sameSite: 'Strict',
secure: true
}]);
await page.evaluate(async () => {
localStorage.setItem('context_probe', 'task-a');
const request = indexedDB.open('context_probe');
await new Promise((resolve, reject) => {
request.onupgradeneeded = () => request.result.createObjectStore('v');
request.onsuccess = () => {
request.result.close();
resolve();
};
request.onerror = () => reject(request.error);
});
const cache = await caches.open('context_probe');
await cache.put('/probe', new Response('task-a'));
});
}
);
await registry.run(
{taskId: 'isolation-fixture-b', trustZone: 'test'},
async (page, context) => {
await page.goto(fixture + '/blank');
assert.deepEqual(await context.cookies(fixture), []);
const residue = await page.evaluate(async () => ({
local: localStorage.getItem('context_probe'),
databases: (await indexedDB.databases()).map((item) => item.name),
caches: await caches.keys(),
service_workers: (
await navigator.serviceWorker.getRegistrations()
).length
}));
assert.equal(residue.local, null);
assert.ok(!residue.databases.includes('context_probe'));
assert.ok(!residue.caches.includes('context_probe'));
assert.equal(residue.service_workers, 0);
}
);Persist cleanup authority across restart
Mirror each registry entry and its complete ever-created page population to a durable controller-owned cleanup table before the context becomes usable. cleanupPending=true is retryable authority, not a terminal result: a reconciler reopens the browser/control-plane inventory, retries close by contextId, appends the missing terminal event, and reruns lifecycle replay. Delete the live handle only after effective destruction and replay both succeed; retain a terminal tombstone and page-population digest for audit. An evidence outage may prevent PASS, but it must never make a possibly live context disappear from the cleanup population.
AID-I-008.001-G002
Security model and factual boundary
Cookies, localStorage, and IndexedDB are origin-scoped by the browser; a vendor origin cannot simply read another origin's cookies. The practical risk is reuse of authenticated state when the agent returns to the same origin, plus shared browser-context surfaces such as permissions, cache behavior, downloads, service-worker state, and cross-origin navigation history. Separate contexts by signed trust set so an untrusted visit cannot share that task state. Use one context per exact unlisted origin rather than a wildcard public bucket.
Signed exact-origin manifest
{
"schema_version": "aidefend.browser-trust-sets.v1",
"policy_version": "browser-trust/2026.07",
"unlisted_origin_effect": "isolate_per_origin",
"trust_sets": {
"internal": [
"https://wiki.internal.corp",
"https://tickets.internal.corp"
],
"vendor": [
"https://console.aws.amazon.com",
"https://portal.azure.com"
]
}
}Verify the manifest signature, validity, deployment scope, and rollback-resistant version before constructing the router. Entries are exact canonical origins including scheme and non-default port; paths, wildcards, URL credentials, and duplicate membership are rejected.
Composable trust-set router
// File: browser/trust_set_router.js
import { createHash } from 'node:crypto';
function digest(text) {
return 'sha256:' + createHash('sha256').update(text).digest('hex');
}
function canonicalOrigin(raw) {
const url = new URL(raw);
if (
!['http:', 'https:'].includes(url.protocol)
|| url.username || url.password
) throw new Error('unsupported or credential-bearing browser URL');
return url.origin;
}
export class TrustSetRouter {
constructor({taskId, signedManifest, contextRegistry, evidenceWriter}) {
if (
!taskId
|| signedManifest.schema_version
!== 'aidefend.browser-trust-sets.v1'
|| signedManifest.unlisted_origin_effect !== 'isolate_per_origin'
|| !signedManifest.policy_sha256
) throw new Error('verified signed trust-set manifest required');
this.taskId = taskId;
this.manifest = signedManifest;
this.registry = contextRegistry;
this.evidence = evidenceWriter;
this.originToTrustSet = new Map();
this.contexts = new Map();
for (const [trustSet, origins] of Object.entries(
signedManifest.trust_sets
)) {
if (!/^[a-z][a-z0-9_-]{0,63}$/.test(trustSet)) {
throw new Error('invalid trust-set identifier');
}
if (!Array.isArray(origins) || origins.length === 0) {
throw new Error('trust set must contain exact origins');
}
for (const declared of origins) {
const origin = canonicalOrigin(declared);
if (origin !== declared || this.originToTrustSet.has(origin)) {
throw new Error('non-canonical or duplicate trust-set origin');
}
this.originToTrustSet.set(origin, trustSet);
}
}
}
classify(rawUrl) {
const origin = canonicalOrigin(rawUrl);
const named = this.originToTrustSet.get(origin);
return named
? {origin, contextKey: 'set:' + named, trustSet: named}
: {
origin,
contextKey: 'unlisted:' + digest(origin),
trustSet: 'unlisted_exact_origin'
};
}
async installNavigationBoundary(entry) {
await entry.context.route('**/*', async (route) => {
const request = route.request();
if (!request.isNavigationRequest()) {
await route.fallback();
return;
}
const target = this.classify(request.url());
if (target.contextKey !== entry.contextKey) {
await route.abort('blockedbyclient');
await this.evidence.appendAndVerify({
schema_version: 'aidefend.browser-trust-transition.v1',
event: 'navigation_blocked_for_context_rotation',
task_id: this.taskId,
policy_sha256: this.manifest.policy_sha256,
policy_version: this.manifest.policy_version,
source_context_id: entry.contextId,
source_context_key: entry.contextKey,
target_origin: target.origin,
target_context_key: target.contextKey,
outcome: 'FAIL'
});
return;
}
await route.fallback();
});
}
async contextFor(rawUrl) {
const target = this.classify(rawUrl);
let entry = this.contexts.get(target.contextKey);
if (entry) {
try {
await this.evidence.appendAndVerify({
schema_version: 'aidefend.browser-trust-transition.v1',
event: 'context_selected',
task_id: this.taskId,
policy_sha256: this.manifest.policy_sha256,
policy_version: this.manifest.policy_version,
context_id: entry.contextId,
context_key: entry.contextKey,
target_origin: target.origin,
reused: true,
outcome: 'PASS'
});
} catch (evidenceError) {
entry.cleanupPending = true;
try {
await this.registry.close(
entry.contextId, 'reuse evidence unavailable'
);
this.contexts.delete(target.contextKey);
} catch (cleanupError) {
throw new AggregateError(
[evidenceError, cleanupError],
'reuse evidence failed and context cleanup is unresolved'
);
}
throw evidenceError;
}
return Object.assign({reused: true}, entry);
}
const created = await this.registry.create({
taskId: this.taskId,
trustZone: target.contextKey
});
entry = {
context: created.context,
contextId: created.contextId,
contextKey: target.contextKey,
trustSet: target.trustSet,
origin: target.origin,
cleanupPending: false
};
try {
await this.installNavigationBoundary(entry);
await this.evidence.appendAndVerify({
schema_version: 'aidefend.browser-trust-transition.v1',
event: 'context_selected',
task_id: this.taskId,
policy_sha256: this.manifest.policy_sha256,
policy_version: this.manifest.policy_version,
context_id: entry.contextId,
context_key: entry.contextKey,
target_origin: target.origin,
reused: false,
outcome: 'PASS'
});
this.contexts.set(target.contextKey, entry);
} catch (evidenceOrInstallError) {
entry.cleanupPending = true;
this.contexts.set(target.contextKey, entry);
try {
await this.registry.close(
entry.contextId, 'trust boundary installation unresolved'
);
this.contexts.delete(target.contextKey);
} catch (cleanupError) {
throw new AggregateError(
[evidenceOrInstallError, cleanupError],
'context setup failed and cleanup is unresolved'
);
}
throw evidenceOrInstallError;
}
return Object.assign({reused: false}, entry);
}
async navigate(rawUrl) {
const entry = await this.contextFor(rawUrl);
const page = entry.context.pages()[0] || await entry.context.newPage();
await page.goto(rawUrl, {waitUntil: 'domcontentloaded'});
return {page, contextId: entry.contextId, trustSet: entry.trustSet};
}
async closeAll() {
const pairs = Array.from(this.contexts.entries());
const results = await Promise.allSettled(pairs.map(([, entry]) =>
this.registry.close(entry.contextId, 'trust-set router closed')
));
const failures = [];
results.forEach((result, index) => {
const [contextKey, entry] = pairs[index];
if (result.status === 'fulfilled') {
this.contexts.delete(contextKey);
} else {
entry.cleanupPending = true;
failures.push(result.reason);
}
});
if (failures.length) {
throw new AggregateError(
failures,
'one or more trust-set contexts remain pending cleanup'
);
}
}
}Composition rule: ensure this navigation boundary executes first. Playwright context routes run in reverse registration order, so register the general AID-I-008.002 request guard first, then register this boundary; call route.fallback() for accepted requests so one canonical route chain observes the complete population. A redirect or popup into another trust set is blocked in the old context; the controller must deliberately call contextFor() for the newly observed exact origin. Never copy cookies, storage state, caches, or downloads while rotating.
Verify safely
replay classification and selection for every navigation and test default deny, origin confusion, IDN, port, redirect, popup, parked context, and rollback.
Open-source tools
- Playwright
- Puppeteer
- Chromium
- Firefox Multi-Account Containers
Source-available / open-weight tools
- Browserless self-hosted (SSPL-1.0; source-available)
Commercial / hosted tools
- Browserbase
- Kasm Workspaces (ephemeral containerized browser-session isolation; lifecycle evidence remains deployment-owned)
- Cloudflare Browser Isolation
- Menlo Secure Cloud Browser
Threat mappings
MITRE ATLAS
- AML.T0113 Steal Web Session Cookie (fresh browser contexts keep cookies out of shared agent state)
- AML.T0091.001 Use Alternate Authentication Material: Web Session Cookie (context teardown destroys reusable authenticated cookies)
- AML.T0089 Enterprise Environment Discovery (fresh per-task browser contexts expose no prior history, sessions, or cached identity to discovery)
MAESTRO
- Data Exfiltration (L2)
OWASP LLM Top 10 2026
- LLM02:2026 Sensitive Information Disclosure
OWASP ML Top 10 2023
- N/A
OWASP Top 10 for Agentic Applications 2026
- ASI03:2026 Identity and Privilege Abuse
NIST Adversarial Machine Learning 2025
- NISTAML.036 Leaking information from user interactions
Cisco Integrated AI Security and Safety Framework
- AISubtech-14.1.2 Insufficient Access Controls (fresh task and trust-zone contexts prevent reuse of another task's authenticated browser session)
- AISubtech-4.2.2 Session Boundary Violation (fresh browser storage partitions prevent task and trust-set state carryover)
- AITech-4.2 Context Boundary Attacks (fresh browser storage partitions prevent task and trust-set state carryover)
- AITech-8.2 Data Exfiltration / Exposure (browser session and storage isolation limits cross-task access and disclosure)
Google Secure AI Framework 2.0 - Risks
- SDD: Sensitive Data Disclosure
Databricks AI Security Framework 3.0
- Agents - Tools MCP Client 13.30: Client-Side Data Leakage
- Agents - Tools MCP Client 13.34: Session and State Management Failures