AID-I-008.002: Cross-Origin Read/Write Segmentation with Step-Up Confirmation
Pillars: app
Lifecycle: building, operation
Scope boundary
Owns the browser-specific observation and enforcement adapter for sensitive or cross-origin writes: complete request interception, stable actionable-element observation, exact request and UI binding, suspension, post-approval re-observation, drift-triggered receipt revocation, and resumption of the same request. It does not create a second generic approval service.
AID-H-018.003: AID-I-008.002 observes, suspends, rebinds, and resumes the exact browser action; AID-H-018.003 independently validates the high-impact action and issues the canonical approval receipt. The browser adapter supplies the exact observed binding but must consume the canonical H-018 decision rather than authorizing the action itself.
AID-H-018.006: AID-I-008.002 re-observes browser state and revokes the receipt on browser-specific drift; AID-H-018.006 owns generic execution-time authorization re-verification and anti-TOCTOU policy. The browser adapter implements domain-specific evidence and suspension without replacing the canonical runtime authorization check.
AID-D-015: AID-I-008.002 enforces the browser action gate; AID-D-015 detects missing, mismatched, expired, replayed, or bypassed approval evidence from independent telemetry. The detector does not authorize or resume a request, and the enforcement adapter does not replace independent bypass monitoring.
Implementation guidance
- AID-I-008.002-G001: Intercept the complete task-scoped browser request population and bind each sensitive or cross-origin write to an exact request plus a stable actionable-element observation, consuming the canonical H-018 receipt only after an unchanged re-observation.
- AID-I-008.002-G002: Suspend a sensitive browser action, render a trustworthy confirmation card from observed request facts, and hand the exact canonical action to AID-H-018.003 for approval before resuming it.
- AID-I-008.002-G003: Externalize browser origin, operation, and effect classification in a signed policy that the request guard loads fail-closed and re-evaluates for every navigation and write.
AID-I-008.002-G001
When to use browser-origin isolation
This method applies to Playwright-controlled HTTP(S) tasks that can create a fresh BrowserContext, install routing before the first page, block service workers, and block unsupported WebSockets. AID-H-018.003 remains the approval owner and AID-H-018.006 remains the generic anti-TOCTOU owner. The code below supplies their browser-specific facts; it does not mint approval locally. A non-browser transport is NOT_APPLICABLE. A browser whose complete request population cannot be intercepted is INSUFFICIENT_DATA and stays blocked.
Signed origin policy
{
"schema_version": "aidefend.i008002-origin-policy.v1",
"policy_version": "browser-origin/2026.07",
"arming_window_ms": 5000,
"service_timeout_ms": 5000,
"max_service_response_bytes": 65536,
"rules": [
{"origin": "https://wiki.internal.corp", "effect": "read_only"},
{"origin": "https://tickets.internal.corp", "effect": "read_write"},
{"origin": "https://payments.vendor.com", "effect": "sensitive"}
]
}Sign the exact policy bytes with Cosign and mount the policy, signature, public key, mTLS material, and offline JWK sets read-only. Numeric values above are examples in this signed environment policy, not framework defaults.
Shared mTLS and signed-receipt client
// File: security/signed_control_client.js
// package.json: jose 6.x, undici 7.x, json-canonicalize 2.x
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { canonicalize } from 'json-canonicalize';
import { createLocalJWKSet, jwtVerify } from 'jose';
import { Agent, fetch } from 'undici';
export function sha256(value) {
const bytes = Buffer.isBuffer(value)
? value
: Buffer.from(canonicalize(value), 'utf8');
return 'sha256:' + createHash('sha256').update(bytes).digest('hex');
}
function assertExpected(claims, expected) {
for (const [name, value] of Object.entries(expected)) {
if (!Object.hasOwn(claims, name) || claims[name] !== value) {
throw new Error('signed receipt binding mismatch: ' + name);
}
}
}
export class SignedControlClient {
constructor({
baseUrl, caPath, certPath, keyPath, jwksPath,
issuer, audience, timeoutMs, maxResponseBytes
}) {
const parsed = new URL(baseUrl);
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
throw new Error('control service must use credential-free HTTPS URL');
}
this.baseUrl = parsed;
this.issuer = issuer;
this.audience = audience;
this.timeoutMs = timeoutMs;
this.maxResponseBytes = maxResponseBytes;
this.jwks = createLocalJWKSet(JSON.parse(readFileSync(jwksPath, 'utf8')));
this.dispatcher = new Agent({
connect: {
ca: readFileSync(caPath),
cert: readFileSync(certPath),
key: readFileSync(keyPath),
rejectUnauthorized: true,
servername: parsed.hostname
}
});
}
async verify(token, expected, typ) {
const verified = await jwtVerify(token, this.jwks, {
algorithms: ['EdDSA'],
issuer: this.issuer,
audience: this.audience,
typ,
clockTolerance: 5,
requiredClaims: ['iss', 'aud', 'iat', 'nbf', 'exp', 'jti']
});
if (
verified.protectedHeader.alg !== 'EdDSA'
|| verified.protectedHeader.typ !== typ
) throw new Error('unexpected signed receipt envelope');
assertExpected(verified.payload, expected);
return {
token,
claims: verified.payload,
receipt_sha256: sha256(Buffer.from(token, 'utf8'))
};
}
async post(pathname, body, expected, typ) {
const target = new URL(pathname, this.baseUrl);
if (target.origin !== this.baseUrl.origin) {
throw new Error('control request escaped pinned origin');
}
const response = await fetch(target, {
method: 'POST',
redirect: 'error',
dispatcher: this.dispatcher,
signal: AbortSignal.timeout(this.timeoutMs),
headers: {'content-type': 'application/json'},
body: canonicalize(body)
});
const declaredLength = Number(response.headers.get('content-length'));
if (!response.body) {
throw new Error('control service response body is unavailable');
}
if (
Number.isFinite(declaredLength)
&& declaredLength > this.maxResponseBytes
) throw new Error('control service response exceeds size policy');
const reader = response.body.getReader();
const chunks = [];
let total = 0;
try {
while (true) {
const {done, value} = await reader.read();
if (done) break;
const bytes = Buffer.from(value);
total += bytes.length;
if (total > this.maxResponseBytes) {
await reader.cancel();
throw new Error('control service response exceeds size policy');
}
chunks.push(bytes);
}
} finally {
reader.releaseLock();
}
const bytes = Buffer.concat(chunks, total);
if (!response.ok) {
throw new Error('control service response rejected');
}
const mediaType = response.headers.get('content-type') || '';
if (!mediaType.toLowerCase().startsWith('application/json')) {
throw new Error('control service response type rejected');
}
const envelope = JSON.parse(bytes.toString('utf8'));
if (
!envelope || Object.keys(envelope).length !== 1
|| typeof envelope.receipt_jws !== 'string'
) throw new Error('control service envelope invalid');
return this.verify(envelope.receipt_jws, expected, typ);
}
}Canonical H-018 adapter
// File: h018/canonical_gate_adapter.js
import { SignedControlClient } from '../security/signed_control_client.js';
const client = new SignedControlClient({
baseUrl: process.env.H018_BASE_URL,
caPath: process.env.H018_CA_PATH,
certPath: process.env.H018_CLIENT_CERT_PATH,
keyPath: process.env.H018_CLIENT_KEY_PATH,
jwksPath: process.env.H018_JWKS_PATH,
issuer: process.env.H018_ISSUER,
audience: 'browser-write-guard',
timeoutMs: Number(process.env.CONTROL_TIMEOUT_MS),
maxResponseBytes: Number(process.env.CONTROL_MAX_RESPONSE_BYTES)
});
export class H018DeniedError extends Error {}
export async function requestH018Receipt(input) {
const result = await client.post('/v1/action-gates/request', {
schema_version: 'aidefend.h018-browser-request.v1',
action_id: input.actionId,
action_digest: input.actionDigest,
policy_version: input.policyVersion,
browser_evidence: input.browserEvidence
}, {
action_id: input.actionId,
action_digest: input.actionDigest,
policy_version: input.policyVersion
}, 'h018-action-gate+jwt');
if (result.claims.decision !== 'ALLOW') {
throw new H018DeniedError('canonical action gate denied');
}
return result;
}
export async function verifyH018Receipt(receipt, expected) {
return client.verify(receipt.token, {
action_id: expected.actionId,
action_digest: expected.actionDigest,
policy_version: expected.policyVersion,
decision: 'ALLOW'
}, 'h018-action-gate+jwt');
}
export async function consumeH018Receipt(receipt, input) {
return client.post('/v1/action-gates/consume', {
schema_version: 'aidefend.h018-browser-consume.v1',
receipt_jws: receipt.token,
action_id: input.actionId,
action_digest: input.actionDigest,
execution_binding: input.executionBinding
}, {
action_id: input.actionId,
action_digest: input.actionDigest,
execution_binding: input.executionBinding,
state: 'CONSUMED'
}, 'h018-action-consume+jwt');
}
export async function revokeH018Receipt(receipt, input) {
return client.post('/v1/action-gates/revoke', {
schema_version: 'aidefend.h018-browser-revoke.v1',
receipt_jws: receipt.token,
action_id: input.actionId,
action_digest: input.actionDigest,
reason: input.reason
}, {
action_id: input.actionId,
action_digest: input.actionDigest,
state: 'REVOKED'
}, 'h018-action-revoke+jwt');
}Signed evidence and independent replay adapters
// File: browser/browser_evidence_client.js
import { SignedControlClient, sha256 } from '../security/signed_control_client.js';
function configuredClient(prefix, audience) {
return new SignedControlClient({
baseUrl: process.env[prefix + '_BASE_URL'],
caPath: process.env[prefix + '_CA_PATH'],
certPath: process.env[prefix + '_CLIENT_CERT_PATH'],
keyPath: process.env[prefix + '_CLIENT_KEY_PATH'],
jwksPath: process.env[prefix + '_JWKS_PATH'],
issuer: process.env[prefix + '_ISSUER'],
audience,
timeoutMs: Number(process.env.CONTROL_TIMEOUT_MS),
maxResponseBytes: Number(process.env.CONTROL_MAX_RESPONSE_BYTES)
});
}
const writer = configuredClient('BROWSER_EVIDENCE_WRITE', 'browser-write-guard');
const reader = configuredClient('BROWSER_EVIDENCE_READ', 'browser-evidence-verifier');
export async function appendAndVerifySignedBrowserEvidence(event) {
const eventSha256 = sha256(event);
return writer.post('/v1/events', {
schema_version: 'aidefend.browser-evidence-append.v1',
event,
event_sha256: eventSha256
}, {
control: 'AID-I-008.002',
task_id: event.task_id,
event_id: event.event_id,
event_sha256: eventSha256,
state: 'STORED'
}, 'browser-evidence+jwt');
}
export async function requestIndependentBrowserReplay(input) {
return reader.post('/v1/replay', Object.assign({
schema_version: 'aidefend.browser-evidence-replay-request.v1'
}, input), {
control: 'AID-I-008.002',
task_id: input.task_id,
policy_sha256: input.policy_sha256,
expected_http_count: input.expected_http_count,
expected_websocket_count: input.expected_websocket_count,
expected_population_root: input.expected_population_root,
outcome: input.expected_gated_count === 0 ? 'NOT_APPLICABLE' : 'PASS'
}, 'browser-evidence-replay+jwt');
}
Complete Playwright enforcement adapter
// File: browser/exact_write_guard.js
import { execFileSync } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { canonicalize } from 'json-canonicalize';
import {
H018DeniedError,
consumeH018Receipt,
requestH018Receipt,
revokeH018Receipt,
verifyH018Receipt
} from '../h018/canonical_gate_adapter.js';
import {
appendAndVerifySignedBrowserEvidence,
requestIndependentBrowserReplay
} from './browser_evidence_client.js';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const EFFECTS = new Set(['read_only', 'read_write', 'sensitive']);
const SECURITY_HEADERS = new Set([
'authorization', 'content-type', 'cookie', 'idempotency-key', 'if-match',
'origin', 'referer', 'x-csrf-token'
]);
class InsufficientData extends Error {}
class BindingDrift extends Error {}
function digestBytes(bytes) {
return 'sha256:' + createHash('sha256').update(bytes).digest('hex');
}
function digestObject(value) {
return digestBytes(Buffer.from(canonicalize(value), 'utf8'));
}
function canonicalUrl(raw) {
const url = new URL(raw);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new InsufficientData('unsupported or credential-bearing URL');
}
url.hash = '';
return url.href;
}
function exactOrigin(raw) {
return new URL(canonicalUrl(raw)).origin;
}
function httpOriginOrNull(raw) {
try {
return exactOrigin(raw);
} catch {
return null;
}
}
const RUNTIME_PROFILE_VERSION = process.env.AIDEFEND_RUNTIME_PROFILE_VERSION;
const RUNTIME_PROFILE_SHA256 = process.env.AIDEFEND_RUNTIME_PROFILE_SHA256;
const POLICY_VERIFY_TIMEOUT_MS = Number(
process.env.AIDEFEND_POLICY_VERIFY_TIMEOUT_MS
);
if (
!RUNTIME_PROFILE_VERSION
|| !/^[0-9a-f]{64}$/.test(RUNTIME_PROFILE_SHA256 || '')
|| !Number.isInteger(POLICY_VERIFY_TIMEOUT_MS)
|| POLICY_VERIFY_TIMEOUT_MS < 1
) throw new Error('verified runtime bootstrap profile is invalid');
function loadSignedPolicy(path, bundlePath, publicKeyPath) {
const payload = readFileSync(path);
const bundle = readFileSync(bundlePath);
const publicKey = readFileSync(publicKeyPath);
if (!payload.length || !bundle.length || !publicKey.length) {
throw new Error('signed origin policy payload, bundle, or trust key is empty');
}
const snapshotRoot = mkdtempSync(
join(tmpdir(), 'aidefend-i008002-policy-')
);
chmodSync(snapshotRoot, 0o700);
let raw;
try {
const payloadSnapshot = join(snapshotRoot, 'origin-policy.json');
const bundleSnapshot = join(snapshotRoot, 'origin-policy.sigstore.json');
const publicKeySnapshot = join(snapshotRoot, 'origin-policy.pub');
writeFileSync(payloadSnapshot, payload, {flag: 'wx', mode: 0o400});
writeFileSync(bundleSnapshot, bundle, {flag: 'wx', mode: 0o400});
writeFileSync(publicKeySnapshot, publicKey, {flag: 'wx', mode: 0o400});
execFileSync('cosign', [
'verify-blob', '--key', publicKeySnapshot,
'--bundle', bundleSnapshot, payloadSnapshot
], {stdio: ['ignore', 'pipe', 'pipe'], timeout: POLICY_VERIFY_TIMEOUT_MS});
raw = readFileSync(payloadSnapshot);
if (
!raw.equals(payload)
|| !readFileSync(bundleSnapshot).equals(bundle)
|| !readFileSync(publicKeySnapshot).equals(publicKey)
) {
throw new Error(
'verified origin policy payload, bundle, or key snapshot changed'
);
}
} finally {
rmSync(snapshotRoot, {recursive: true, force: true});
}
const policy = JSON.parse(raw.toString('utf8'));
const expectedKeys = [
'arming_window_ms', 'max_service_response_bytes', 'policy_version',
'rules', 'schema_version', 'service_timeout_ms'
];
if (
!policy
|| Object.keys(policy).sort().join(',') !== expectedKeys.join(',')
|| policy.schema_version !== 'aidefend.i008002-origin-policy.v1'
|| typeof policy.policy_version !== 'string'
|| !Number.isInteger(policy.arming_window_ms)
|| !Number.isInteger(policy.service_timeout_ms)
|| !Number.isInteger(policy.max_service_response_bytes)
|| policy.arming_window_ms < 1
|| policy.service_timeout_ms < 1
|| policy.max_service_response_bytes < 1
|| !Array.isArray(policy.rules)
) throw new Error('signed origin policy schema invalid');
if (
Number(process.env.CONTROL_TIMEOUT_MS) !== policy.service_timeout_ms
|| Number(process.env.CONTROL_MAX_RESPONSE_BYTES)
!== policy.max_service_response_bytes
) throw new Error('service client limits differ from signed policy');
const seen = new Set();
for (const rule of policy.rules) {
if (
!rule
|| Object.keys(rule).sort().join(',') !== 'effect,origin'
|| exactOrigin(rule.origin) !== rule.origin
|| !EFFECTS.has(rule.effect)
|| seen.has(rule.origin)
) throw new Error('signed origin rule invalid or duplicated');
seen.add(rule.origin);
}
return {policy, policySha256: digestBytes(raw)};
}
async function observeActionable(locator, frameId, expectedFrameUrl) {
if (await locator.count() !== 1) {
throw new InsufficientData('actionable locator must resolve exactly once');
}
const ariaSnapshot = await locator.ariaSnapshot();
const bounds = await locator.boundingBox();
const state = await locator.evaluate(async (element) => {
const hash = async (text) => {
const data = new TextEncoder().encode(text);
const bytes = await crypto.subtle.digest('SHA-256', data);
return 'sha256:' + Array.from(
new Uint8Array(bytes),
(item) => item.toString(16).padStart(2, '0')
).join('');
};
const form = element.form || element.closest('form');
const value = typeof element.value === 'string' ? element.value : '';
const attributes = Array.from(element.attributes)
.map((attribute) => [attribute.name, attribute.value])
.sort((left, right) => left[0].localeCompare(right[0]));
return {
document_url: location.href,
tag: element.tagName.toLowerCase(),
id: element.id || null,
attributes,
disabled: Boolean(element.disabled),
aria_disabled: element.getAttribute('aria-disabled'),
form_action: form ? form.action : null,
form_method: form ? form.method.toUpperCase() : null,
dom_subtree_sha256: await hash(element.outerHTML),
value_sha256: await hash(value)
};
});
if (
!bounds || typeof ariaSnapshot !== 'string' || !ariaSnapshot.trim()
|| canonicalUrl(state.document_url) !== canonicalUrl(expectedFrameUrl)
) throw new InsufficientData('stable DOM and accessibility observation unavailable');
const accessibilitySha256 = digestBytes(Buffer.from(ariaSnapshot, 'utf8'));
const preActionStateSha256 = digestObject({
bounds,
disabled: state.disabled,
aria_disabled: state.aria_disabled,
value_sha256: state.value_sha256,
dom_subtree_sha256: state.dom_subtree_sha256
});
const controllerElementId = digestObject({
frame_id: frameId,
tag: state.tag,
id: state.id,
attributes: state.attributes,
form_action: state.form_action,
form_method: state.form_method,
dom_subtree_sha256: state.dom_subtree_sha256,
accessibility_subtree_sha256: accessibilitySha256
});
const binding = {
frame_id: frameId,
controller_element_id: controllerElementId,
dom_subtree_sha256: state.dom_subtree_sha256,
accessibility_subtree_sha256: accessibilitySha256,
pre_action_state_sha256: preActionStateSha256,
form_action: state.form_action,
form_method: state.form_method
};
return Object.assign(binding, {binding_sha256: digestObject(binding)});
}
async function requestFacts(request, frameId, policyRecord) {
const url = canonicalUrl(request.url());
const body = request.postDataBuffer() || Buffer.alloc(0);
const headers = (await request.headersArray())
.map((entry) => ({
name: entry.name.toLowerCase(),
value: entry.value
}))
.filter((entry) => SECURITY_HEADERS.has(entry.name))
.sort((left, right) =>
left.name.localeCompare(right.name)
|| left.value.localeCompare(right.value)
);
let frame = null;
try {
frame = request.frame();
} catch {
frame = null;
}
const frameUrl = frame ? frame.url() : null;
const page = frame ? frame.page() : null;
return {
frame,
facts: {
transport: 'playwright_http',
frame_id: frameId,
initial_navigation: Boolean(
request.isNavigationRequest() && httpOriginOrNull(frameUrl) === null
),
top_origin: page ? httpOriginOrNull(page.mainFrame().url()) : null,
source_origin: frameUrl ? httpOriginOrNull(frameUrl) : null,
target_origin: new URL(url).origin,
method: request.method().toUpperCase(),
canonical_url_sha256: digestBytes(Buffer.from(url, 'utf8')),
body_sha256: digestBytes(body),
security_headers_sha256: digestObject(headers),
policy_version: policyRecord.policy.policy_version,
policy_sha256: policyRecord.policySha256
}
};
}
export class ExactBrowserWriteGuard {
constructor({taskId, policyPath, policySignature, policyPublicKey}) {
if (typeof taskId !== 'string' || !taskId) {
throw new TypeError('trusted taskId required');
}
this.taskId = taskId;
this.policyRecord = loadSignedPolicy(
policyPath, policySignature, policyPublicKey
);
this.context = null;
this.frameIds = new WeakMap();
this.armed = new Map();
this.pending = new Set();
this.populationReceipts = [];
this.evidenceErrors = [];
this.nextSequence = 1;
this.httpObserved = 0;
this.httpSettled = 0;
this.websocketObserved = 0;
this.websocketSettled = 0;
this.gatedCount = 0;
this.closing = false;
}
frameId(frame) {
if (!this.frameIds.has(frame)) this.frameIds.set(frame, randomUUID());
return this.frameIds.get(frame);
}
classify(origin) {
const match = this.policyRecord.policy.rules.find(
(rule) => rule.origin === origin
);
return match ? match.effect : 'unseen';
}
track(promise) {
this.pending.add(promise);
promise.then(
() => this.pending.delete(promise),
() => this.pending.delete(promise)
);
return promise;
}
async evidence(event) {
return appendAndVerifySignedBrowserEvidence(Object.assign({
schema_version: 'aidefend.i008002-browser-event.v1',
control: 'AID-I-008.002',
task_id: this.taskId,
policy_version: this.policyRecord.policy.policy_version,
policy_sha256: this.policyRecord.policySha256,
runtime_profile_version: RUNTIME_PROFILE_VERSION,
runtime_profile_sha256: RUNTIME_PROFILE_SHA256
}, event));
}
async createContext(browser) {
if (this.context) throw new Error('guard context already exists');
const context = await browser.newContext({
serviceWorkers: 'block',
permissions: []
});
if (typeof context.routeWebSocket !== 'function') {
await context.close();
throw new InsufficientData('WebSocket population cannot be enforced');
}
await context.route('**/*', (route) => this.track(this.handleRoute(route)));
await context.routeWebSocket(/.*/, (socket) =>
this.track(this.handleWebSocket(socket))
);
this.context = context;
return context;
}
async armAction({frame, locator}) {
if (!this.context || this.closing) throw new Error('guard is not active');
const frameId = this.frameId(frame);
if (this.armed.has(frameId)) {
throw new InsufficientData('only one action may be armed per frame');
}
const preActionBinding = await observeActionable(
locator, frameId, frame.url()
);
const actionNonce = randomUUID();
this.armed.set(frameId, {
actionNonce,
frame,
locator,
preActionBinding,
expiresAt: Date.now() + this.policyRecord.policy.arming_window_ms
});
return {
action_nonce: actionNonce,
pre_action_binding_sha256: preActionBinding.binding_sha256
};
}
async appendTerminal(event) {
try {
const receipt = await this.evidence(event);
this.populationReceipts.push({
sequence: event.sequence,
event_sha256: receipt.claims.event_sha256,
receipt_sha256: receipt.receipt_sha256
});
} catch (error) {
this.evidenceErrors.push({
sequence: event.sequence,
error_class: error.constructor.name
});
throw error;
}
}
async handleWebSocket(socket) {
const sequence = this.nextSequence++;
const eventId = randomUUID();
this.websocketObserved += 1;
try {
socket.close({
code: 1008,
reason: 'state-changing WebSockets require a separate control'
});
await this.appendTerminal({
event_id: eventId,
sequence,
population: 'websocket',
outcome: 'PASS',
reason: 'unsupported_websocket_blocked'
});
} catch (error) {
this.evidenceErrors.push({
sequence,
error_class: error.constructor.name
});
} finally {
this.websocketSettled += 1;
}
}
async handleRoute(route) {
const sequence = this.nextSequence++;
const eventId = randomUUID();
const request = route.request();
this.httpObserved += 1;
let requestFactsRecord = null;
let terminal = null;
let actionDigest = null;
let h018ReceiptSha256 = null;
let resumed = false;
try {
if (this.closing) {
throw new InsufficientData('task is closing');
}
let frameId = null;
try {
frameId = this.frameId(request.frame());
} catch {
frameId = null;
}
const observed = await requestFacts(
request, frameId, this.policyRecord
);
requestFactsRecord = observed.facts;
const classification = this.classify(requestFactsRecord.target_origin);
if (request.serviceWorker()) {
throw new InsufficientData('service-worker request escaped block policy');
}
if (requestFactsRecord.initial_navigation) {
if (!SAFE_METHODS.has(requestFactsRecord.method)) {
throw new InsufficientData('state-changing initial navigation lacks source binding');
}
await route.continue();
resumed = true;
terminal = {
outcome: 'NOT_APPLICABLE',
reason: 'observed_safe_initial_navigation'
};
return;
}
if (!observed.frame || !requestFactsRecord.source_origin) {
throw new InsufficientData('request lacks frame and source-origin binding');
}
const requiresGate = !SAFE_METHODS.has(requestFactsRecord.method) && (
classification !== 'read_write'
|| requestFactsRecord.source_origin !== requestFactsRecord.target_origin
);
if (!requiresGate) {
await route.continue();
resumed = true;
terminal = {
outcome: 'NOT_APPLICABLE',
reason: 'read_or_policy_allowed_same_origin_write'
};
return;
}
this.gatedCount += 1;
const armed = this.armed.get(frameId);
this.armed.delete(frameId);
if (
!armed || armed.frame !== observed.frame
|| Date.now() > armed.expiresAt
) throw new InsufficientData('write has no fresh pre-observed action');
if (armed.preActionBinding.form_action && (
digestBytes(Buffer.from(
canonicalUrl(armed.preActionBinding.form_action), 'utf8'
)) !== requestFactsRecord.canonical_url_sha256
|| armed.preActionBinding.form_method !== requestFactsRecord.method
)) throw new InsufficientData('form action or method does not bind request');
const actionId = randomUUID();
const boundAction = {
schema_version: 'aidefend.i008002-bound-browser-action.v1',
action_id: actionId,
action_nonce: armed.actionNonce,
task_id: this.taskId,
request_facts: requestFactsRecord,
pre_action_binding: armed.preActionBinding
};
actionDigest = digestObject(boundAction);
const receipt = await requestH018Receipt({
actionId,
actionDigest,
policyVersion: requestFactsRecord.policy_version,
browserEvidence: boundAction
});
await verifyH018Receipt(receipt, {
actionId,
actionDigest,
policyVersion: requestFactsRecord.policy_version
});
const finalBinding = await observeActionable(
armed.locator, frameId, observed.frame.url()
);
if (
finalBinding.binding_sha256
!== armed.preActionBinding.binding_sha256
) {
let revokeState = 'receipt_revoked';
try {
await revokeH018Receipt(receipt, {
actionId,
actionDigest,
reason: 'browser_binding_drift'
});
} catch (revokeError) {
revokeState = 'revoke_error_' + revokeError.constructor.name;
}
throw new BindingDrift(
'DOM or accessibility binding drifted; ' + revokeState
);
}
const executionBinding = digestObject({
action_digest: actionDigest,
final_binding_sha256: finalBinding.binding_sha256,
request_facts_sha256: digestObject(requestFactsRecord)
});
const consumed = await consumeH018Receipt(receipt, {
actionId,
actionDigest,
executionBinding
});
h018ReceiptSha256 = consumed.receipt_sha256;
const ready = await this.evidence({
event_id: randomUUID(),
sequence,
population: 'authorization',
stage: 'authorized_pending_resume',
outcome: 'INSUFFICIENT_DATA',
reason: 'authorization_consumed_request_not_resumed',
action_digest: actionDigest,
h018_consume_receipt_sha256: h018ReceiptSha256,
request_facts: requestFactsRecord,
pre_action_binding: armed.preActionBinding,
final_binding: finalBinding
});
await route.continue();
resumed = true;
terminal = {
outcome: 'PASS',
reason: 'exact_request_authorized_consumed_and_resumed',
authorization_evidence_sha256: ready.receipt_sha256
};
} catch (error) {
const outcome = error instanceof BindingDrift ? 'FAIL'
: error instanceof H018DeniedError ? 'FAIL'
: error instanceof InsufficientData ? 'INSUFFICIENT_DATA'
: 'ERROR';
terminal = {
outcome,
reason: String(error.message || error),
error_class: error.constructor ? error.constructor.name : 'Error'
};
if (!resumed) {
try {
await route.abort('blockedbyclient');
} catch {
terminal.abort_state = 'route_already_closed';
}
}
} finally {
try {
await this.appendTerminal(Object.assign({
event_id: eventId,
sequence,
population: 'http_request',
request_facts: requestFactsRecord,
action_digest: actionDigest,
h018_receipt_sha256: h018ReceiptSha256,
resumed
}, terminal || {
outcome: 'ERROR',
reason: 'terminal decision missing'
}));
} catch {
if (!resumed) {
try {
await route.abort('blockedbyclient');
} catch {
// Population reconciliation reports the missing terminal receipt.
}
}
}
this.httpSettled += 1;
}
}
async closeAndVerify() {
if (!this.context) throw new Error('guard context does not exist');
this.closing = true;
await this.context.close();
while (this.pending.size) {
await Promise.allSettled(Array.from(this.pending));
}
const expectedPopulation = this.httpObserved + this.websocketObserved;
if (
this.httpObserved !== this.httpSettled
|| this.websocketObserved !== this.websocketSettled
|| this.populationReceipts.length !== expectedPopulation
|| this.evidenceErrors.length
) throw new Error('ERROR: intercepted population or evidence incomplete');
const ordered = this.populationReceipts
.slice()
.sort((left, right) => left.sequence - right.sequence);
const populationRoot = digestObject(ordered);
const replay = await requestIndependentBrowserReplay({
task_id: this.taskId,
policy_sha256: this.policyRecord.policySha256,
expected_http_count: this.httpObserved,
expected_websocket_count: this.websocketObserved,
expected_gated_count: this.gatedCount,
expected_population_root: populationRoot
});
if (
replay.claims.verified_population_count !== expectedPopulation
|| replay.claims.verified_population_root !== populationRoot
) throw new Error('FAIL: independent replay population mismatch');
return replay;
}
}Usage
const guard = new ExactBrowserWriteGuard({
taskId,
policyPath: 'policy/origin-policy.json',
policySignature: 'policy/origin-policy.sig',
policyPublicKey: 'keys/browser-origin-policy.pub'
});
const context = await guard.createContext(browser);
const page = await context.newPage();
await page.goto('https://payments.vendor.com/start');
const submit = page.getByRole('button', {
name: 'Submit payment',
exact: true
});
await guard.armAction({frame: page.mainFrame(), locator: submit});
await submit.click();
const independentReceipt = await guard.closeAndVerify();Verify browser-origin enforcement
Install the route before the first page so the initial navigation is part of the measured population. The pre-action binding is captured by armAction() before click initiation and includes distinct DOM-subtree and accessibility-subtree digests; the suspended request then binds exact method, canonical-URL digest, source/top/target origins, body digest, and selected security-header digest. The writer identity cannot perform independent replay because replay uses separate mTLS credentials and audience. Action: deploy all three modules, pin their mTLS and JWK material read-only, route every navigation and request through the guard, and require closeAndVerify() before task closure.
Pin policy authority and reconcile resumed requests
The constructor's three policy paths are deployment inputs, not request fields: resolve them from one root-owned current-manifest that binds the immutable policy bytes, detached signature, trusted key ID/generation, monotonic policy generation, and rollback floor. Re-open and verify that exact manifest immediately before route.continue(); a changed current pointer, revoked key, stale generation, or same-generation byte mutation blocks the request and requires a new guard instance. If terminal evidence fails after resumed=true, the request may already have reached its destination. Set closing=true, durably insert an EFFECT_UNCERTAIN reconciliation row keyed by eventId/sequence/actionDigest and the authorization/request digests, close the browser context, and have a separately credentialed adapter query the authoritative destination or idempotency record. Only that reconciler may move the row to EFFECT_CONFIRMED or NO_EFFECT_CONFIRMED; missing readback remains blocked. Never rewrite it as an ordinary deny or PASS.
AID-I-008.002-G002
Build the card from trusted observations
When the browser guard intercepts a sensitive write, derive the displayed operation, destination origin, authenticated account or tenant, resource, amount or permission delta, selected form fields, and risk reason from the suspended request plus fresh DOM and accessibility observations. Place the confirmation UI in browser or controller chrome outside page-controlled DOM. Bind the card to a digest of the canonical method, URL, body, destination, task, session, actionable-element observation, policy revision, and expiry; page text is descriptive input, never the authority for these fields.
Use the canonical approval broker
Submit that digest and sanitized card facts to AID-H-018.003. This step does not create a second approval policy or approval store. On an authentic approval receipt, re-observe the actionable element and request, require the same digest, and hand the unchanged receipt to the execution-time guard; deny on cancellation, expiry, navigation, DOM/ARIA drift, request mutation, receipt replay, or broker uncertainty. Test deceptive overlays, lookalike origins, iframe changes, hidden fields, amount changes, and approval after redirect. Preserve card and action digests, broker receipt digest, re-observation result, and resume/abort evidence.
AID-I-008.002-G003
Version the browser effect policy
Define canonical origins, allowed HTTP methods, operation classes, read-only versus state-changing effects, cross-origin rules, redirect behavior, WebSocket posture, confirmation requirements, freshness, and exception expiry in a signed schema-validated policy. Resolve internationalized hostnames, default ports, URL normalization, and corporate proxy routes before comparison. Unknown origin or operation is not silently read-only; it follows the policy's fail-closed or explicit review path.
Prove effective policy on every worker
Load the policy from a root-owned location, verify signature and rollback floor, and atomically replace only a fully validated revision. Record the policy digest with each intercepted request. Exercise subdomain confusion, alternate ports, redirects, mixed schemes, signed-policy rollback, stale worker caches, unknown methods, service workers, and WebSocket upgrades. A separate reader queries every browser worker and reconciles the effective revision with the deployment population; policy load or coverage uncertainty blocks sensitive writes and is ERROR.
Current-generation policy contract
The root-owned loader must consume one signed current-manifest whose payload contains the policy artifact digest, policy generation, signer key ID/generation, rollback floor, validity window, and expected interceptor deployment generation. Verify the manifest under the pinned trust root, then verify the exact policy bytes it names. A caller, browser page, or environment variable may select neither the policy path nor key. Before each sensitive resume and before final PASS, compare the still-current manifest and every worker's effective digest/generation with the loaded snapshot; rotation or uncertainty blocks new writes until workers reload. A key can remain cryptographically valid while revoked or superseded, so key currentness is a separate required check.
Open-source tools
- Playwright
- Puppeteer
- Cosign (signed origin-policy bundle verification; browser enforcement remains external)
- Open Policy Agent (OPA) (cross-origin action and step-up policy decisions)
Commercial / hosted tools
- Browserbase
Threat mappings
MITRE ATLAS
- AML.T0051.001 LLM Prompt Injection: Indirect
- AML.T0086 Exfiltration via AI Agent Tool Invocation (cross-origin write enforcement blocks unauthorized outbound browser writes)
- AML.T0101 Data Destruction via AI Agent Tool Invocation
MAESTRO
- Agent Tool Misuse (L7)
- Integration Risks (L7)
OWASP LLM Top 10 2026
- LLM03:2026 Excessive Agency
- LLM07:2026 Misinformation (request and UI readback prevent false state from driving sensitive writes)
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
- ASI09:2026 Human-Agent Trust Exploitation (step-up confirmation forces user verification before agent commits to a manipulated cross-origin action)
NIST Adversarial Machine Learning 2025
- NISTAML.039 Compromising connected resources
Cisco Integrated AI Security and Safety Framework
- AISubtech-12.1.3 Unsafe System / Browser / File Execution (sensitive and cross-origin browser effects require exact observed authorization)
- AISubtech-14.1.2 Insufficient Access Controls (browser-side action binding contains misuse of authenticated delegated sessions)
- AISubtech-2.1.1 Execution Approval Bypass (browser-side suspension enforces current approval for the exact consequential write)
- AISubtech-4.3.4 Replay Exploitation (one-time approval consumption is bound to the unchanged browser request)
- AISubtech-4.3.6 Cross-Origin Exploitation
- AITech-12.1 Tool Exploitation (sensitive and cross-origin browser effects require exact observed authorization)
- AITech-14.2 Abuse of Delegated Authority (browser-side action binding contains misuse of authenticated delegated sessions)
- AITech-2.1 Excessive Agency (browser-side suspension enforces current approval for the exact consequential write)
Google Secure AI Framework 2.0 - Risks
- RA: Rogue Actions
- IIC: Insecure Integrated Component
Databricks AI Security Framework 3.0
- Agents - Core 13.2: Tool Misuse
- Agents - Core 13.6: Intent Breaking & Goal Manipulation