AID-I-008.003: Download & Clipboard Transfer Quarantine
Pillars: app, data
Lifecycle: building, operation
Scope boundary
Owns quarantine, scan-evidence binding, clean-context handoff, and cross-task or cross-origin controls for browser downloads and clipboard import or export. It does not broker authentication-bearing navigation or decide general URL admission.
AID-I-008.005: AID-I-008.003 quarantines downloaded bytes and clipboard transfers; AID-I-008.005 isolates magic links, OAuth callbacks, password resets, invitations, and other authentication-bearing navigation. An authentication URL is not a file-transfer artifact, and download quarantine does not protect state, nonce, PKCE, one-time code, or redirect binding.
AID-H-019: AID-I-008.003 governs files and clipboard content crossing out of or into the browser workspace; AID-H-019 governs URL admission, safe fetch, content demotion, and browser observation export. Safe-fetch results can supply scan inputs, but this control owns the transfer quarantine and clean-context release decision.
Implementation guidance
- AID-I-008.003-G001: Quarantine browser downloads until they are scanned, approved, and reopened in a clean context.
- AID-I-008.003-G002: Block silent clipboard reuse across tasks and require approval before cross-origin paste or clipboard import.
AID-I-008.003-G001
Storage quarantine boundary
This method applies when Playwright downloads are enabled. Quarantine and release are separate root-owned noexec,nodev,nosuid mounts; neither is mounted into the model workspace. Filenames, task IDs, and URLs never become filesystem paths. The controller uses random object IDs, exclusive no-follow opens, a signed size policy, and two independently signed decisions: malware/content scanning and task release authorization.
Fail-closed download quarantine
// File: browser/download_quarantine.js
import { constants } from 'node:fs';
import { lstat, mkdir, open, unlink } from 'node:fs/promises';
import path from 'node:path';
import { createHash, randomUUID } from 'node:crypto';
async function requireProtectedDirectory(root) {
await mkdir(root, {recursive: true, mode: 0o700});
const stat = await lstat(root);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error('download root is not a real directory');
}
if ((stat.mode & 0o077) !== 0) {
throw new Error('download root permissions are too broad');
}
}
async function writeAll(handle, bytes) {
let offset = 0;
while (offset < bytes.length) {
const result = await handle.write(
bytes, offset, bytes.length - offset, null
);
if (result.bytesWritten < 1) throw new Error('short quarantine write');
offset += result.bytesWritten;
}
}
async function copyAndVerify(sourcePath, targetPath, expectedDigest, maxBytes) {
const source = await open(
sourcePath, constants.O_RDONLY | constants.O_NOFOLLOW
);
const target = await open(
targetPath,
constants.O_WRONLY | constants.O_CREAT
| constants.O_EXCL | constants.O_NOFOLLOW,
0o600
);
const hash = createHash('sha256');
let size = 0;
let copied = false;
try {
for await (const chunk of source.createReadStream({autoClose: false})) {
size += chunk.length;
if (size > maxBytes) throw new Error('release size exceeds policy');
hash.update(chunk);
await writeAll(target, chunk);
}
await target.sync();
copied = true;
} finally {
await Promise.allSettled([source.close(), target.close()]);
if (!copied) await unlink(targetPath).catch(() => undefined);
}
const digest = 'sha256:' + hash.digest('hex');
if (digest !== expectedDigest) {
await unlink(targetPath).catch(() => undefined);
throw new Error('quarantine bytes changed before release');
}
return {digest, size};
}
async function requireAbsent(filePath) {
try {
await lstat(filePath);
} catch (error) {
if (error && error.code === 'ENOENT') return;
throw error;
}
throw new Error('quarantine cleanup readback found residual bytes');
}
export class DownloadQuarantine {
constructor({
quarantineRoot, releaseRoot, signedPolicy,
scanClient, releaseAuthority, evidence
}) {
if (
signedPolicy.schema_version !== 'aidefend.download-policy.v1'
|| !signedPolicy.policy_sha256
|| !Number.isInteger(signedPolicy.max_bytes)
) throw new Error('verified signed download policy required');
Object.assign(this, {
quarantineRoot, releaseRoot, policy: signedPolicy,
scanClient, releaseAuthority, evidence
});
this.pending = new Set();
}
async initialize() {
await requireProtectedDirectory(this.quarantineRoot);
await requireProtectedDirectory(this.releaseRoot);
}
install(page, taskId) {
page.on('download', (download) => {
const operation = this.handle(download, taskId);
this.pending.add(operation);
operation.then(
() => this.pending.delete(operation),
() => this.pending.delete(operation)
);
});
}
async handle(download, taskId) {
const downloadId = randomUUID();
const quarantinePath = path.join(this.quarantineRoot, downloadId);
const handle = await open(
quarantinePath,
constants.O_WRONLY | constants.O_CREAT
| constants.O_EXCL | constants.O_NOFOLLOW,
0o600
);
const hash = createHash('sha256');
let size = 0;
try {
const stream = await download.createReadStream();
if (!stream) throw new Error('browser download stream unavailable');
for await (const chunk of stream) {
size += chunk.length;
if (size > this.policy.max_bytes) {
await download.cancel();
throw new Error('download exceeds signed size policy');
}
hash.update(chunk);
await writeAll(handle, chunk);
}
await handle.sync();
} finally {
await handle.close();
}
const browserFailure = await download.failure();
if (browserFailure) throw new Error('browser download failed');
const contentSha256 = 'sha256:' + hash.digest('hex');
const scan = await this.scanClient.scanAndVerify({
task_id: taskId,
download_id: downloadId,
content_sha256: contentSha256,
size_bytes: size,
policy_sha256: this.policy.policy_sha256
});
const release = await this.releaseAuthority.authorizeAndVerify({
task_id: taskId,
download_id: downloadId,
content_sha256: contentSha256,
size_bytes: size,
scan_receipt_sha256: scan.receipt_sha256,
policy_sha256: this.policy.policy_sha256
});
if (scan.decision !== 'ALLOW' || release.decision !== 'ALLOW') {
await this.evidence.appendAndVerify({
control: 'AID-I-008.003',
event: 'download_blocked',
task_id: taskId,
download_id: downloadId,
content_sha256: contentSha256,
size_bytes: size,
scan_receipt_sha256: scan.receipt_sha256,
release_receipt_sha256: release.receipt_sha256,
outcome: 'FAIL'
});
throw new Error('download scan or release policy denied');
}
const releaseId = randomUUID();
const releasePath = path.join(this.releaseRoot, releaseId);
const copied = await copyAndVerify(
quarantinePath, releasePath, contentSha256, this.policy.max_bytes
);
const prepared = await this.evidence.appendAndVerify({
control: 'AID-I-008.003',
event: 'download_release_prepared',
task_id: taskId,
download_id: downloadId,
release_staging_id: releaseId,
content_sha256: copied.digest,
size_bytes: copied.size,
scan_receipt_sha256: scan.receipt_sha256,
release_receipt_sha256: release.receipt_sha256,
outcome: 'INSUFFICIENT_DATA'
});
await unlink(quarantinePath);
await requireAbsent(quarantinePath);
const readback = await this.releaseAuthority.verifyReleasedAndCleanup({
task_id: taskId,
download_id: downloadId,
release_staging_id: releaseId,
content_sha256: copied.digest,
size_bytes: copied.size,
prepared_evidence_receipt_sha256: prepared.receipt_sha256,
policy_sha256: this.policy.policy_sha256
});
const receipt = await this.evidence.appendAndVerify({
control: 'AID-I-008.003',
event: 'download_released',
task_id: taskId,
download_id: downloadId,
release_artifact_id: readback.release_artifact_id,
content_sha256: copied.digest,
size_bytes: copied.size,
scan_receipt_sha256: scan.receipt_sha256,
release_receipt_sha256: release.receipt_sha256,
prepared_evidence_receipt_sha256: prepared.receipt_sha256,
release_readback_receipt_sha256: readback.receipt_sha256,
quarantine_absent: true,
outcome: 'PASS'
});
return {
release_artifact_id: readback.release_artifact_id,
content_sha256: copied.digest,
evidence_receipt_sha256: receipt.receipt_sha256
};
}
async drain() {
const results = await Promise.allSettled(Array.from(this.pending));
const failed = results.filter((result) => result.status === 'rejected');
if (failed.length) throw new AggregateError(
failed.map((result) => result.reason),
'one or more downloads were not safely resolved'
);
}
}Enforce the owner and mount boundary
Replace the permission-only directory check with an effective mount readback. On Linux, require the exact quarantine and release roots to be root-owned real directories and separate mount points whose current options include noexec,nodev,nosuid; the controller's service identity receives only its documented file access through the host policy.
import { execFile as execFileCallback } from 'node:child_process';
import { promisify } from 'node:util';
const execFile = promisify(execFileCallback);
import { lstat, mkdir, realpath } from 'node:fs/promises';
async function requireProtectedMount(root, timeoutMs) {
await mkdir(root, {recursive: true, mode: 0o700});
const resolved = await realpath(root);
const stat = await lstat(resolved);
if (
!stat.isDirectory() || stat.isSymbolicLink()
|| stat.uid !== 0 || (stat.mode & 0o077) !== 0
) throw new Error('download root ownership or mode invalid');
const {stdout} = await execFile(
'/usr/bin/findmnt',
['--json', '--target', resolved, '--output', 'TARGET,OPTIONS'],
{timeout: timeoutMs, maxBuffer: 65_536, windowsHide: true}
);
const parsed = JSON.parse(stdout);
if (!parsed || !Array.isArray(parsed.filesystems) || parsed.filesystems.length !== 1) {
throw new Error('download mount readback population differs');
}
const mount = parsed.filesystems[0];
const options = new Set(String(mount.options || '').split(','));
if (
await realpath(String(mount.target)) !== resolved
|| !['noexec', 'nodev', 'nosuid'].every((item) => options.has(item))
) throw new Error('download root is not the required protected mount');
return {resolved, options: Array.from(options).sort()};
}Resolve timeoutMs from the signed download policy and retain the two mount readbacks with the policy digest.
Persist operations and reconcile every crash point
The in-memory pending set is only a wait list; it must not own audit population. Before creating a quarantine object, conditionally reserve a durable row keyed by download_id and bind task, policy, quarantine/release roots, and the browser download identity. Advance it through CAPTURING, QUARANTINED, APPROVED, RELEASE_STAGED, SOURCE_REMOVED, and RELEASED with object IDs, byte count/digest, scan/release receipts, and evidence receipts. Denial, scanner outage, short write, copy/readback failure, and unlink failure remain durable BLOCKED or ERROR rows; never delete the row when a promise rejects. A restart reconciler opens both objects with no-follow semantics, recomputes bounded digests, and performs the one safe next transition. Only a verified RELEASED terminal row can expose the opaque release artifact ID; ambiguous or missing bytes remain quarantined.
AID-I-008.003-G002
Security boundary
Deny OS clipboard access to the model workspace and browser context. Copy and paste become calls from a trusted controller to an mTLS broker that encrypts task-scoped payloads, returns random opaque handles, and atomically consumes a handle only for an exact target binding. Do not expose a page binding that arbitrary site JavaScript can call, and do not keep plaintext in a process-global map.
Controller-side exact-target adapter
// File: browser/clipboard_transfer_guard.js
import { createHash } from 'node:crypto';
function digestBytes(bytes) {
return 'sha256:' + createHash('sha256').update(bytes).digest('hex');
}
function canonicalOrigin(raw) {
const url = new URL(raw);
if (
!['http:', 'https:'].includes(url.protocol)
|| url.username
|| url.password
) {
throw new Error('clipboard origin must be credential-free HTTP(S)');
}
return url.origin;
}
async function observePasteTarget(locator, frameUrl) {
if (await locator.count() !== 1) {
throw new Error('paste target must resolve exactly once');
}
const observed = await locator.evaluate(async (element) => {
if (
!(element instanceof HTMLInputElement)
&& !(element instanceof HTMLTextAreaElement)
&& !element.isContentEditable
) throw new Error('unsupported paste target type');
const hash = async (value) => {
const bytes = await crypto.subtle.digest(
'SHA-256', new TextEncoder().encode(value)
);
return 'sha256:' + Array.from(
new Uint8Array(bytes),
(item) => item.toString(16).padStart(2, '0')
).join('');
};
const attrs = Array.from(element.attributes)
.filter((attribute) => attribute.name !== 'value')
.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,
name: element.getAttribute('name'),
attributes_sha256: await hash(JSON.stringify(attrs)),
dom_subtree_sha256: await hash(element.outerHTML),
disabled: Boolean(element.disabled),
read_only: Boolean(element.readOnly)
};
});
if (
canonicalOrigin(observed.document_url) !== canonicalOrigin(frameUrl)
|| observed.disabled || observed.read_only
) throw new Error('paste target is unavailable or frame-misbound');
const binding = {
origin: canonicalOrigin(observed.document_url),
tag: observed.tag,
id: observed.id,
name: observed.name,
attributes_sha256: observed.attributes_sha256,
dom_subtree_sha256: observed.dom_subtree_sha256
};
binding.binding_sha256 = digestBytes(
Buffer.from(JSON.stringify(binding), 'utf8')
);
return binding;
}
async function readTargetDigest(locator) {
return locator.evaluate(async (element) => {
const value = 'value' in element ? String(element.value) : element.textContent;
const bytes = await crypto.subtle.digest(
'SHA-256', new TextEncoder().encode(value || '')
);
return 'sha256:' + Array.from(
new Uint8Array(bytes),
(item) => item.toString(16).padStart(2, '0')
).join('');
});
}
export class ClipboardTransferGuard {
constructor({taskId, signedPolicy, broker, evidence}) {
if (
!taskId
|| signedPolicy.schema_version !== 'aidefend.clipboard-policy.v1'
|| !signedPolicy.policy_sha256
) throw new Error('verified clipboard policy and task required');
Object.assign(this, {taskId, policy: signedPolicy, broker, evidence});
}
async capture({sourceOrigin, text}) {
if (typeof text !== 'string') throw new TypeError('clipboard text required');
const bytes = Buffer.from(text, 'utf8');
if (bytes.length > this.policy.max_payload_bytes) {
throw new Error('clipboard payload exceeds signed policy');
}
const contentSha256 = digestBytes(bytes);
const result = await this.broker.storeAndVerify({
task_id: this.taskId,
source_origin: canonicalOrigin(sourceOrigin),
content_utf8: text,
content_sha256: contentSha256,
size_bytes: bytes.length,
policy_sha256: this.policy.policy_sha256
});
text = '';
return {
handle: result.opaque_handle,
content_sha256: contentSha256,
store_receipt_sha256: result.receipt_sha256
};
}
async paste({handleRecord, targetFrame, targetLocator}) {
const preBinding = await observePasteTarget(
targetLocator, targetFrame.url()
);
const materialized = await this.broker.authorizeConsumeAndVerify({
task_id: this.taskId,
opaque_handle: handleRecord.handle,
expected_content_sha256: handleRecord.content_sha256,
store_receipt_sha256: handleRecord.store_receipt_sha256,
target_origin: preBinding.origin,
target_binding_sha256: preBinding.binding_sha256,
policy_sha256: this.policy.policy_sha256
});
let finalBinding = null;
let postPasteSha256 = null;
let prepared = null;
let failureCode = 'PASTE_APPLICATION_ERROR';
let failureOutcome = 'ERROR';
try {
finalBinding = await observePasteTarget(
targetLocator, targetFrame.url()
);
if (finalBinding.binding_sha256 !== preBinding.binding_sha256) {
failureCode = 'TARGET_BINDING_DRIFT';
failureOutcome = 'FAIL';
throw new Error('paste target drifted after approval');
}
const materializedDigest = digestBytes(
Buffer.from(materialized.content_utf8, 'utf8')
);
if (materializedDigest !== handleRecord.content_sha256) {
failureCode = 'MATERIALIZED_DIGEST_MISMATCH';
failureOutcome = 'FAIL';
throw new Error('broker materialized a different clipboard payload');
}
let pasteText = materialized.content_utf8;
try {
await targetLocator.fill(pasteText);
} finally {
pasteText = '';
materialized.content_utf8 = '';
}
postPasteSha256 = await readTargetDigest(targetLocator);
if (postPasteSha256 !== handleRecord.content_sha256) {
failureCode = 'POST_PASTE_DIGEST_MISMATCH';
failureOutcome = 'FAIL';
throw new Error('post-paste target digest mismatch');
}
prepared = await this.evidence.appendAndVerify({
schema_version: 'aidefend.clipboard-transfer.v1',
control: 'AID-I-008.003',
event: 'paste_applied_pending_cleanup',
task_id: this.taskId,
source_content_sha256: handleRecord.content_sha256,
store_receipt_sha256: handleRecord.store_receipt_sha256,
consume_receipt_sha256: materialized.receipt_sha256,
target_origin: finalBinding.origin,
target_binding_sha256: finalBinding.binding_sha256,
post_paste_sha256: postPasteSha256,
policy_sha256: this.policy.policy_sha256,
outcome: 'INSUFFICIENT_DATA'
});
} catch (error) {
materialized.content_utf8 = '';
const cleanup = await this.broker.destroyAfterFailureAndVerify({
opaque_handle: handleRecord.handle,
consume_receipt_jws: materialized.consume_receipt_jws,
failure_code: failureCode,
policy_sha256: this.policy.policy_sha256
});
handleRecord.handle = null;
const failureReceipt = await this.evidence.appendAndVerify({
schema_version: 'aidefend.clipboard-transfer.v1',
control: 'AID-I-008.003',
event: 'paste_failed_cleanup_complete',
task_id: this.taskId,
source_content_sha256: handleRecord.content_sha256,
store_receipt_sha256: handleRecord.store_receipt_sha256,
consume_receipt_sha256: materialized.receipt_sha256,
target_origin: finalBinding?.origin ?? preBinding.origin,
target_binding_sha256:
finalBinding?.binding_sha256 ?? preBinding.binding_sha256,
post_paste_sha256: postPasteSha256,
policy_sha256: this.policy.policy_sha256,
failure_code: failureCode,
destruction_receipt_sha256: cleanup.receipt_sha256,
broker_object_absent: true,
outcome: failureOutcome
});
error.evidence_receipt_sha256 = failureReceipt.receipt_sha256;
throw error;
}
const destruction = await this.broker.destroyAndVerify({
opaque_handle: handleRecord.handle,
consume_receipt_jws: materialized.consume_receipt_jws,
evidence_receipt_sha256: prepared.receipt_sha256
});
handleRecord.handle = null;
const receipt = await this.evidence.appendAndVerify({
schema_version: 'aidefend.clipboard-transfer.v1',
control: 'AID-I-008.003',
event: 'paste_completed',
task_id: this.taskId,
source_content_sha256: handleRecord.content_sha256,
store_receipt_sha256: handleRecord.store_receipt_sha256,
consume_receipt_sha256: materialized.receipt_sha256,
target_origin: finalBinding.origin,
target_binding_sha256: finalBinding.binding_sha256,
post_paste_sha256: postPasteSha256,
policy_sha256: this.policy.policy_sha256,
prepared_evidence_receipt_sha256: prepared.receipt_sha256,
destruction_receipt_sha256: destruction.receipt_sha256,
broker_object_absent: true,
outcome: 'PASS'
});
return {evidence_receipt_sha256: receipt.receipt_sha256};
}
}Broker contract: the root-owned broker accepts only the controller workload identity; stores ciphertext under an unpredictable handle; binds tenant/task, source origin, content digest and size, policy, issue and expiry; and never logs plaintext or the raw handle. Cross-origin or cross-task materialization consumes the canonical policy or human approval, and every consume uses a conditional one-time ledger write. Responses carry locally verified signed receipts. The broker deletes ciphertext after successful paste; destroyAfterFailureAndVerify() performs the same deletion and independent absence readback after target drift, substitution, or application failure. A durable broker-side consume lease and reconciler destroy every non-final handle if the controller or evidence sink fails. The browser context has empty clipboard permissions and the desktop workspace blocks host clipboard device/API access.
Open-source tools
- Playwright
- ClamAV
- YARA
Commercial / hosted tools
- Cloudflare Browser Isolation
- VirusTotal Enterprise
- OPSWAT MetaDefender Core
- Menlo Secure Cloud Browser
- Authentic8 Silo Web Isolation Platform
Threat mappings
MITRE ATLAS
- AML.T0086 Exfiltration via AI Agent Tool Invocation (clipboard export quarantine blocks tool-mediated exfiltration)
- AML.T0100 AI Agent Clickbait (download and clipboard quarantine interrupts lures that induce code copying or file execution; general click and URL admission remain outside scope)
MAESTRO
- Data Exfiltration (L2)
- Integration Risks (L7)
OWASP LLM Top 10 2026
- LLM02:2026 Sensitive Information Disclosure
- LLM10:2026 Improper Output Handling (downloads and clipboard transfers are quarantined before interpretation or reuse)
OWASP ML Top 10 2023
- N/A
OWASP Top 10 for Agentic Applications 2026
- ASI02:2026 Tool Misuse and Exploitation
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 (download execution and file handoff remain quarantined until exact-byte release checks)
- AISubtech-14.1.2 Insufficient Access Controls (task and origin bound clipboard handles prevent silent cross-boundary disclosure)
- AISubtech-4.3.4 Replay Exploitation (one-time clipboard handles are bound to the authorized target and content)
- AISubtech-4.3.6 Cross-Origin Exploitation (cross-origin clipboard transfer requires exact-target broker authorization)
- AISubtech-8.2.3 Data Exfiltration via Agent Tooling (task and origin bound clipboard handles prevent silent cross-boundary disclosure)
- AITech-12.1 Tool Exploitation (download execution and file handoff remain quarantined until exact-byte release checks)
- AITech-8.2 Data Exfiltration / Exposure (task and origin bound clipboard handles prevent silent cross-boundary disclosure)
Google Secure AI Framework 2.0 - Risks
- SDD: Sensitive Data Disclosure
- RA: Rogue Actions
Databricks AI Security Framework 3.0
- Agents - Tools MCP Client 13.30: Client-Side Data Leakage