AID-I-008.003: Download & Clipboard Transfer Quarantine

Version 1.20261015 · Actionable sub-technique

Treat browser downloads and clipboard import or export as quarantine-required cross-boundary transfers. Downloads must not be opened until their complete bytes, type, scan evidence, and clean-context handoff satisfy policy; clipboard contents must not be silently reused across tasks, origins, tenants, or trust zones.

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.
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

Block silent clipboard reuse across tasks and require approval before cross-origin paste or clipboard import.
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

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-I-008.003: Download & Clipboard Transfer Quarantine | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version