AID-H-001: Adversarial Robustness Training

Version 1.20261015 · Actionable technique

Proactively improve a model's resistance to declared adversarial-input and model-level evasion threats through adversarial examples, robust training objectives, and architecture-specific regularization methods whose benefit is demonstrated by independent replay without unacceptable task-quality loss.

Pillars: model

Lifecycle: building, validation

Scope boundary

This technique owns training-time changes that make a model measurably more resistant to declared adversarial-input and model-level evasion threats, including adversarial-example training, robust objectives, and architecture-specific regularization, together with threat-specific pass criteria verified by independent replay. Architecture or representation choices such as relative position bias or rotary positional embeddings do not satisfy this technique unless they are independently shown to improve the declared robustness outcome. It does not own evaluation-data integrity, instruction-hierarchy protection against prompt injection, or runtime authorization of tools and side effects.

AID-H-007.004: AID-H-001 changes the model to improve adversarial robustness; AID-H-007.004 protects the evaluation evidence used to substantiate that robustness. AID-H-007.004 covers the integrity, independence, sufficiency, and promotion use of datasets, holdouts, benchmarks, and test evidence. It applies whether or not adversarial training was used, and AID-H-001 cannot compensate for manipulated or insufficient evaluation evidence.

AID-H-016: AID-H-001 targets adversarial examples and model-evasion threats; AID-H-016 protects instruction precedence against prompt injection. Both can involve model training, but AID-H-016 separates trusted instructions from untrusted data and teaches instruction-following systems to preserve the instruction hierarchy. Neither technique substitutes for the other.

AID-H-018: AID-H-001 improves model robustness; AID-H-018 authorizes and constrains runtime tool actions and side effects. AID-H-001 does not authorize tool calls, constrain parameters, or make side effects transaction-safe. AID-H-018 remains necessary regardless of adversarial-training results.

Implementation guidance

AID-H-001-G001

Implement general adversarial example training using a PGD-style, Friendly, or Free variant appropriate to the model and compute budget.
Concept:

For standard classification or sequence models facing evasion attacks, teams usually choose one primary adversarial-example training variant rather than implementing several in parallel. PGD-style training, Friendly Adversarial Training, and Free Adversarial Training all serve the same control objective: expose the model to adversarially perturbed inputs during training so it learns decision boundaries that are harder to exploit.

Choose a variant
Load the verified model-family training profile

Before running any variant, a bootstrap identity must signature-verify the model family's versioned training profile and export these nine required fields: VERIFIED_H001_PGD_LEARNING_RATE, VERIFIED_H001_PGD_WEIGHT_DECAY, VERIFIED_H001_PGD_EPSILON, VERIFIED_H001_PGD_STEP_SIZE, VERIFIED_H001_PGD_MAX_ITERATIONS, VERIFIED_H001_FRIENDLY_KL_WEIGHT, VERIFIED_H001_FREE_REPLAY_COUNT, VERIFIED_H001_FREE_STEP_SIZE, and VERIFIED_H001_FREE_EPSILON. Resolve these model-family thresholds and compute budgets from that approved profile; AIDEFEND defines no universal values. Refuse to start when profile signature, version, digest, or any required export is missing. The runnable Python below validates every consumed value and rejects invalid relationships.

Variant A - PGD-based adversarial training

Generate strong iterative adversarial examples inside an Linf ball and train directly on them. The optimizer is defined once and reused by both the ART wrapper and the PyTorch training loop so the example can run as written.

# File: hardening/adversarial_training.py
import math
import os
import torch
import torch.optim as optim
from art.attacks.evasion import ProjectedGradientDescent
from art.estimators.classification import PyTorchClassifier

def _required_positive_float(name: str) -> float:
    value = float(os.environ[name])
    if not math.isfinite(value) or value <= 0:
        raise RuntimeError(f"{name} must be a finite positive number")
    return value

PGD_LEARNING_RATE = _required_positive_float("VERIFIED_H001_PGD_LEARNING_RATE")
PGD_WEIGHT_DECAY = _required_positive_float("VERIFIED_H001_PGD_WEIGHT_DECAY")
PGD_EPSILON = _required_positive_float("VERIFIED_H001_PGD_EPSILON")
PGD_STEP_SIZE = _required_positive_float("VERIFIED_H001_PGD_STEP_SIZE")


def _required_positive_int(name: str) -> int:
    value = int(os.environ[name])
    if value <= 0:
        raise RuntimeError(f"{name} must be a positive integer")
    return value

PGD_MAX_ITERATIONS = _required_positive_int("VERIFIED_H001_PGD_MAX_ITERATIONS")


if PGD_STEP_SIZE > PGD_EPSILON:
    raise RuntimeError("verified PGD step size exceeds epsilon")


def train_with_pgd(*, model, device, loss_fn, input_shape, class_count, clip_values, train_loader):
    model = model.to(device)
    optimizer = optim.AdamW(model.parameters(), lr=PGD_LEARNING_RATE, weight_decay=PGD_WEIGHT_DECAY)
    
    art_classifier = PyTorchClassifier(
        model=model,
        loss=loss_fn,
        optimizer=optimizer,
        input_shape=input_shape,
        nb_classes=class_count,
        clip_values=clip_values,
        device_type="gpu" if device.type == "cuda" else "cpu",
    )
    
    pgd_attack = ProjectedGradientDescent(
        estimator=art_classifier,
        eps=PGD_EPSILON,
        eps_step=PGD_STEP_SIZE,
        max_iter=PGD_MAX_ITERATIONS,
        targeted=False,
    )
    
    for data, target in train_loader:
        model.train()
        data = data.to(device)
        target = target.to(device)
    
        adv_np = pgd_attack.generate(x=data.detach().cpu().numpy())
        adv_tensor = torch.from_numpy(adv_np).to(device=device, dtype=data.dtype)
    
        optimizer.zero_grad(set_to_none=True)
        logits = model(adv_tensor)
        loss = loss_fn(logits, target)
        loss.backward()
        optimizer.step()
    return model
Variant B - Friendly Adversarial Training

Add a KL-divergence term so predictions on adversarial inputs stay close to predictions on clean inputs, which helps preserve benign accuracy.

# File: hardening/friendly_adversarial_training.py
import math
import os
import torch.nn.functional as F

def _required_positive_float(name: str) -> float:
    value = float(os.environ[name])
    if not math.isfinite(value) or value <= 0:
        raise RuntimeError(f"{name} must be a finite positive number")
    return value

FRIENDLY_KL_WEIGHT = _required_positive_float("VERIFIED_H001_FRIENDLY_KL_WEIGHT")


def friendly_adversarial_step(*, model, optimizer, clean_input, adversarial_input, true_label):
    clean_logits = model(clean_input)
    adv_logits = model(adversarial_input)
    
    clean_probs = F.softmax(clean_logits, dim=1)
    adv_log_probs = F.log_softmax(adv_logits, dim=1)
    
    loss_ce = F.cross_entropy(adv_logits, true_label)
    loss_kl = F.kl_div(adv_log_probs, clean_probs.detach(), reduction="batchmean")
    
    total_loss = loss_ce + FRIENDLY_KL_WEIGHT * loss_kl
    optimizer.zero_grad()
    total_loss.backward()
    optimizer.step()
    return total_loss.detach()
Variant C - Free Adversarial Training

Replay the same minibatch several times while updating both the model and an in-batch perturbation to approximate multi-step adversarial training at lower cost.

# File: hardening/free_adversarial_training.py
import math
import os
import torch

def _required_positive_int(name: str) -> int:
    value = int(os.environ[name])
    if value <= 0:
        raise RuntimeError(f"{name} must be a positive integer")
    return value

FREE_REPLAY_COUNT = _required_positive_int("VERIFIED_H001_FREE_REPLAY_COUNT")

def _required_positive_float(name: str) -> float:
    value = float(os.environ[name])
    if not math.isfinite(value) or value <= 0:
        raise RuntimeError(f"{name} must be a finite positive number")
    return value

FREE_STEP_SIZE = _required_positive_float("VERIFIED_H001_FREE_STEP_SIZE")
FREE_EPSILON = _required_positive_float("VERIFIED_H001_FREE_EPSILON")

if FREE_STEP_SIZE > FREE_EPSILON:
    raise RuntimeError("verified Free adversarial step exceeds epsilon")


def train_free_adversarial(*, model, device, optimizer, criterion, dataloader, clip_min: float, clip_max: float):
    if not math.isfinite(clip_min) or not math.isfinite(clip_max) or clip_min >= clip_max:
        raise ValueError("signed input clip interval is invalid")
    for data, target in dataloader:
        data = data.to(device)
        target = target.to(device)
        delta = torch.zeros_like(data, requires_grad=True)
    
        for _ in range(FREE_REPLAY_COUNT):
            optimizer.zero_grad()
            adv = torch.clamp(data + delta, min=clip_min, max=clip_max)
            logits = model(adv)
            loss = criterion(logits, target)
            loss.backward()
    
            with torch.no_grad():
                delta += FREE_STEP_SIZE * delta.grad.sign()
                delta.clamp_(-FREE_EPSILON, FREE_EPSILON)
                delta.grad = None
    
            optimizer.step()
    return model
Validation and evidence

Action: Pick one primary general-purpose adversarial example training variant per model family, document why it was chosen, and tie the training configuration to the resulting robustness report.

Before you begin

Apply AID-H-001-G001 only when the model can be retrained and its signed threat model identifies an evasion, perturbation, patch, occlusion, or model-level attention-manipulation threat addressed by the selected variant.

Keep the action safely bounded

block promotion unless the bound candidate actually used this variant and independent replay meets robustness and task-quality policy.

AID-H-001-G002

For Reinforcement Learning, use a two-player, zero-sum game setup (RARL).
Production implementation: bounded alternating robust RL

RARL is applicable only when the environment exposes a physically meaningful disturbance channel. The signed profile defines action bounds, disturbance cost, alternating updates, seeds, and the robust-return gate; the adversary may not alter observations, rewards, termination flags, or simulator state outside that channel.

# File: robust_rl/alternating_rarl.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
import numpy as np


@dataclass(frozen=True)
class Policy:
    act_fn: Callable[[np.ndarray, bool], np.ndarray]
    update_fn: Callable[[list[dict]], dict]

    def act(self, observation: np.ndarray, deterministic: bool) -> np.ndarray:
        value = np.asarray(self.act_fn(observation, deterministic), dtype=np.float32)
        if value.size == 0 or not np.isfinite(value).all():
            raise RuntimeError("policy emitted an empty or non-finite action")
        return value

    def update(self, transitions: list[dict]) -> dict:
        receipt = self.update_fn(transitions)
        if not isinstance(receipt, dict) or not receipt:
            raise RuntimeError("policy update did not return an evidence receipt")
        return receipt


@dataclass(frozen=True)
class DisturbanceEnv:
    reset_fn: Callable[[int], tuple[np.ndarray, dict]]
    step_fn: Callable[[np.ndarray, np.ndarray], tuple[np.ndarray, float, bool, bool, dict]]

    def reset(self, *, seed: int) -> tuple[np.ndarray, dict]:
        observation, receipt = self.reset_fn(seed)
        value = np.asarray(observation, dtype=np.float32)
        if value.size == 0 or not np.isfinite(value).all() or not isinstance(receipt, dict):
            raise RuntimeError("environment reset contract failed")
        return value, receipt

    def step(self, protagonist_action: np.ndarray, disturbance: np.ndarray) -> tuple[np.ndarray, float, bool, bool, dict]:
        result = self.step_fn(protagonist_action, disturbance)
        if not isinstance(result, tuple) or len(result) != 5:
            raise RuntimeError("environment step contract failed")
        observation, reward, terminated, truncated, receipt = result
        value = np.asarray(observation, dtype=np.float32)
        if value.size == 0 or not np.isfinite(value).all() or type(terminated) is not bool or type(truncated) is not bool or not isinstance(receipt, dict):
            raise RuntimeError("environment step returned invalid evidence")
        return value, float(reward), terminated, truncated, receipt

@dataclass(frozen=True)
class RarlProfile:
    seed: int
    total_rounds: int
    episodes_per_round: int
    maximum_steps: int
    disturbance_l2_maximum: float
    disturbance_cost: float

def bounded_disturbance(raw: np.ndarray, maximum: float) -> np.ndarray:
    value = np.asarray(raw, dtype=np.float32)
    norm = float(np.linalg.norm(value))
    if not np.isfinite(norm):
        raise ValueError("adversary emitted non-finite disturbance")
    return value if norm <= maximum else value * (maximum / norm)

def collect_episode(env: DisturbanceEnv, protagonist: Policy, adversary: Policy, profile: RarlProfile, seed: int) -> tuple[list[dict], float]:
    observation, _ = env.reset(seed=seed)
    transitions, episode_return = [], 0.0
    for step in range(profile.maximum_steps):
        action = protagonist.act(observation, deterministic=False)
        disturbance = bounded_disturbance(adversary.act(observation, deterministic=False), profile.disturbance_l2_maximum)
        next_observation, reward, terminated, truncated, info = env.step(action, disturbance)
        if not np.isfinite(reward):
            raise RuntimeError("environment emitted non-finite reward")
        adversary_reward = -float(reward) - profile.disturbance_cost * float(np.square(disturbance).sum())
        transitions.append({"observation": observation, "protagonist_action": action, "disturbance": disturbance, "protagonist_reward": float(reward), "adversary_reward": adversary_reward, "next_observation": next_observation, "done": bool(terminated or truncated), "step": step, "environment_evidence": info})
        episode_return += float(reward)
        observation = next_observation
        if terminated or truncated:
            break
    return transitions, episode_return

def train(env: DisturbanceEnv, protagonist: Policy, adversary: Policy, profile: RarlProfile) -> list[dict]:
    if profile.total_rounds < 1 or profile.episodes_per_round < 1 or profile.maximum_steps < 1 or profile.disturbance_l2_maximum <= 0 or profile.disturbance_cost < 0:
        raise ValueError("invalid signature-verified RARL profile")
    evidence = []
    for round_index in range(profile.total_rounds):
        batch, returns = [], []
        for episode in range(profile.episodes_per_round):
            transitions, robust_return = collect_episode(env, protagonist, adversary, profile, profile.seed + round_index * profile.episodes_per_round + episode)
            batch.extend(transitions)
            returns.append(robust_return)
        if round_index % 2 == 0:
            update, updated = protagonist.update(batch), "protagonist"
        else:
            update, updated = adversary.update(batch), "adversary"
        evidence.append({"round": round_index, "updated_policy": updated, "episodes": len(returns), "mean_robust_return": float(np.mean(returns)), "update": update})
    return evidence

Independent gate: Freeze both policies and replay the complete signed seed/disturbance matrix against baseline and candidate. Retain per-episode returns, clean return, constraint violations, policy digests, simulator image digest, and profile digest. Promotion requires the policy-defined robust-return improvement without exceeding clean-return or safety budgets.

Keep the action safely bounded

block promotion unless the bound candidate actually used this variant and independent replay meets robustness and task-quality policy.

AID-H-001-G003

Implement patch-based adversarial training for vision models.
Concept:

Expose the model to localized, high-saliency adversarial patches during training so it learns to discount them.

Step 1: Generate and apply patches with ART
import math
import os
import json
import subprocess
import tempfile
from pathlib import Path

import torch
from art.attacks.evasion import AdversarialPatch

def _required_positive_seconds(name: str) -> float:
    value = float(os.environ[name])
    if not math.isfinite(value) or value <= 0:
        raise RuntimeError(f"{name} must be a finite positive number of seconds")
    return value

PATCH_POLICY_VERIFY_TIMEOUT_SECONDS = _required_positive_seconds("VERIFIED_H001_PATCH_POLICY_VERIFY_TIMEOUT_SECONDS")


POLICY_PATH = Path("hardening/patch_training_policy.json")
policy_bytes = POLICY_PATH.read_bytes()
snapshot_path: Path | None = None
try:
    with tempfile.NamedTemporaryFile(prefix="patch-training-policy-", suffix=".json", delete=False) as handle:
        handle.write(policy_bytes)
        handle.flush()
        os.fsync(handle.fileno())
        snapshot_path = Path(handle.name)
    subprocess.run([
        "cosign", "verify-blob", "--key", "keys/patch-training-policy.pub",
        "--bundle", "hardening/patch_training_policy.sig", str(snapshot_path),
    ], check=True, capture_output=True, text=True, timeout=PATCH_POLICY_VERIFY_TIMEOUT_SECONDS)
finally:
    if snapshot_path is not None:
        snapshot_path.unlink(missing_ok=True)

def unique_pairs(pairs):
    value = {}
    for key, item in pairs:
        if key in value:
            raise ValueError("duplicate patch-policy key: " + key)
        value[key] = item
    return value

def reject_constant(value):
    raise ValueError("non-finite patch-policy number: " + value)

signed_training_policy = json.loads(
    policy_bytes.decode("utf-8", errors="strict"),
    object_pairs_hook=unique_pairs, parse_constant=reject_constant,
)
if set(signed_training_policy) != {
    "schema_version", "maximum_attack_iterations", "rotation_max_degrees",
    "scale_minimum", "scale_maximum", "attack_batch_size",
    "patched_loss_weight", "patch_scale",
} or signed_training_policy["schema_version"] != "aidefend.patch-training-policy.v1":
    raise ValueError("unsupported patch training policy schema")

attack_iterations = signed_training_policy.get("maximum_attack_iterations")
if isinstance(attack_iterations, bool) or not isinstance(attack_iterations, int) or attack_iterations < 1:
    raise ValueError("signed maximum_attack_iterations must be a positive integer")
rotation_max_degrees = float(signed_training_policy["rotation_max_degrees"])
scale_minimum = float(signed_training_policy["scale_minimum"])
scale_maximum = float(signed_training_policy["scale_maximum"])
attack_batch_size = signed_training_policy["attack_batch_size"]
patched_loss_weight = float(signed_training_policy["patched_loss_weight"])
if (
    not all(math.isfinite(value) for value in (
        rotation_max_degrees, scale_minimum, scale_maximum, patched_loss_weight
    ))
    or rotation_max_degrees < 0
    or not 0 < scale_minimum <= scale_maximum <= 1
    or isinstance(attack_batch_size, bool)
    or not isinstance(attack_batch_size, int)
    or attack_batch_size < 1
    or not 0 < patched_loss_weight < 1
):
    raise ValueError("signed adversarial-patch profile is invalid")

patch_attack = AdversarialPatch(
    classifier=art_classifier,
    rotation_max=rotation_max_degrees,
    scale_min=scale_minimum,
    scale_max=scale_maximum,
    max_iter=attack_iterations,
    batch_size=attack_batch_size
)

for images, labels in train_loader:
    images = images.to(device)
    labels = labels.to(device)

    imgs_np = images.detach().cpu().numpy()
    patch_np, mask_np = patch_attack.generate(x=imgs_np)
    patch_scale = float(signed_training_policy["patch_scale"])
    if not patch_attack.scale_min <= patch_scale <= patch_attack.scale_max:
        raise ValueError("patch_scale is outside the signed attack profile")
    patched_np = patch_attack.apply_patch(
        imgs_np, scale=patch_scale, patch_external=patch_np, mask=mask_np
    )
    patched = torch.from_numpy(patched_np).to(device)

    optimizer.zero_grad()
    clean_outputs = model(images)
    patched_outputs = model(patched)
    loss = (
        (1.0 - patched_loss_weight) * criterion(clean_outputs, labels)
        + patched_loss_weight * criterion(patched_outputs, labels)
    )
    loss.backward()
    optimizer.step()
Step 2: Keep clean and patched objectives in the same loop

patched_loss_weight comes from the verified training policy and must remain strictly between zero and one, so every admitted batch contributes both clean-task and patch-robustness loss. Promotion still requires a separate clean and patch-augmented validation population.

Action: Use the clean/patched loss weight from the verified training policy and evaluate robustness on separate clean and patch-augmented validation populations.

Before you begin

Apply AID-H-001-G003 only when the model can be retrained and its signed threat model identifies an evasion, perturbation, patch, occlusion, or model-level attention-manipulation threat addressed by the selected variant.

Keep the action safely bounded

block promotion unless the bound candidate actually used this variant and independent replay meets robustness and task-quality policy.

AID-H-001-G004

Employ masking or occlusion-based training as data augmentation.
Before you begin

Apply AID-H-001-G004 only when the model can be retrained and its signed threat model identifies an evasion, perturbation, patch, occlusion, or model-level attention-manipulation threat addressed by the selected variant.

Concept:

Random Erasing / Cutout reduces over-reliance on small regions and improves robustness to localized attacks.

Step 1: Add RandomErasing to your transforms
from torchvision import transforms


def build_occlusion_training_transforms(
    *,
    image_size: tuple[int, int],
    normalize_mean: tuple[float, ...],
    normalize_std: tuple[float, ...],
    horizontal_flip_probability: float,
    erase_probability: float,
    erase_scale: tuple[float, float],
    erase_ratio: tuple[float, float],
    erase_value: float,
):
    # Every argument comes from the signature-verified preprocessing/augmentation profile.
    return transforms.Compose([
        transforms.Resize(image_size),
        transforms.RandomHorizontalFlip(p=horizontal_flip_probability),
        transforms.ToTensor(),
        transforms.Normalize(mean=normalize_mean, std=normalize_std),
        transforms.RandomErasing(
            p=erase_probability,
            scale=erase_scale,
            ratio=erase_ratio,
            value=erase_value,
        ),
    ])
Step 2: Feed the transform through the real training loader
from pathlib import Path

from torch.utils.data import DataLoader
from torchvision.datasets import ImageFolder


def build_occlusion_training_loader(
    *,
    dataset_path: Path,
    training_transforms,
    batch_size: int,
    worker_count: int,
) -> DataLoader:
    if batch_size < 1 or worker_count < 0:
        raise ValueError("signed loader profile is invalid")
    train_dataset = ImageFolder(str(dataset_path), transform=training_transforms)
    return DataLoader(
        train_dataset,
        batch_size=batch_size,
        shuffle=True,
        num_workers=worker_count,
        pin_memory=True,
    )

Verification: Compare the exact baseline and candidate model digests on a versioned clean set and a versioned occlusion/patch set; retain per-case metrics, seeds, transform policy, and signed release result.

Action: Track accuracy under occlusions (synthetic patches/masks) to quantify gains.

Keep the action safely bounded

block promotion unless the bound candidate actually used this variant and independent replay meets robustness and task-quality policy.

AID-H-001-G005

Apply a signed Transformer attention-regularization profile and independently gate the artifact on replayed robustness and task-quality evidence.
Transformer attention-regularization variant

This is one architecture-specific method under AID-H-001, not a separate control objective. AID-H-007.004 owns the integrity and independence of generic evaluation artifacts; this guidance owns the Transformer-specific robustness metric and pass criteria.

Runnable training regularizer
# File: training/attention_entropy_regularization.py
from __future__ import annotations

import math
import torch


def regularized_loss(
    main_loss: torch.Tensor,
    attention_weights: torch.Tensor,
    coefficient: float,
) -> tuple[torch.Tensor, dict]:
    if main_loss.ndim != 0 or not torch.isfinite(main_loss):
        raise ValueError("main_loss must be one finite scalar")
    if (
        not attention_weights.is_floating_point()
        or attention_weights.ndim < 3
        or not torch.isfinite(attention_weights).all()
        or (attention_weights < 0).any()
    ):
        raise ValueError("attention weights must be finite, non-negative floating values")
    if isinstance(coefficient, bool) or not math.isfinite(float(coefficient)) or coefficient <= 0:
        raise ValueError("coefficient must be a finite positive signed-profile value")
    probabilities = attention_weights.clamp_min(
        torch.finfo(attention_weights.dtype).tiny
    )
    row_sums = attention_weights.sum(dim=-1)
    if not torch.allclose(
        row_sums, torch.ones_like(row_sums), atol=1e-5, rtol=1e-5
    ):
        raise ValueError("attention rows must be normalized")
    entropy = -(probabilities * probabilities.log()).sum(dim=-1).mean()
    total = main_loss - float(coefficient) * entropy
    if not torch.isfinite(total):
        raise RuntimeError("regularized loss is not finite")
    return total, {
        "main_loss": float(main_loss.detach().cpu()),
        "attention_entropy": float(entropy.detach().cpu()),
        "coefficient": float(coefficient),
    }

The training workflow verifies the detached signature and exact schema of the versioned regularization profile before constructing coefficient, records the profile digest with every run, and treats unsupported attention tensors as not applicable rather than silently coercing them.

Independent release gate
# File: evaluation/gate_attention_robustness.py
from __future__ import annotations

import hashlib
import hmac
import json
import math
import os
import subprocess
import tempfile
from collections import Counter
from pathlib import Path

def _required_positive_seconds(name: str) -> float:
    value = float(os.environ[name])
    if not math.isfinite(value) or value <= 0:
        raise RuntimeError(f"{name} must be a finite positive number of seconds")
    return value

ATTENTION_ARTIFACT_VERIFY_TIMEOUT_SECONDS = _required_positive_seconds("VERIFIED_H001_ATTENTION_ARTIFACT_VERIFY_TIMEOUT_SECONDS")
ATTENTION_VERDICT_SIGN_TIMEOUT_SECONDS = _required_positive_seconds("VERIFIED_H001_ATTENTION_VERDICT_SIGN_TIMEOUT_SECONDS")
ATTENTION_VERDICT_VERIFY_TIMEOUT_SECONDS = _required_positive_seconds("VERIFIED_H001_ATTENTION_VERDICT_VERIFY_TIMEOUT_SECONDS")



POLICY_IDENTITY = Path("/opt/aidefend/trust/attention-policy.identity").read_text(encoding="utf-8").strip()
EVALUATOR_IDENTITY = Path("/opt/aidefend/trust/attention-evaluator.identity").read_text(encoding="utf-8").strip()
OIDC_ISSUER = Path("/opt/aidefend/trust/github-actions.issuer").read_text(encoding="utf-8").strip()
VERDICT_VERIFY_KEY = Path("/opt/aidefend/trust/attention-release-gate.pub")
POLICY_FIELDS = {
    "schema_version", "policy_version", "required_threat_contexts",
    "minimum_cases_per_context", "minimum_robustness_improvement",
    "task_quality_minimums", "maximum_task_quality_drop",
    "metric_definition_sha256", "baseline_profile_sha256",
    "candidate_profile_sha256", "evaluation_corpus_sha256",
    "attack_implementation_sha256", "seed_policy_sha256",
}
METRIC_FIELDS = {
    "schema_version", "definition_id", "robustness_formula",
    "attack_success_field", "task_metrics",
}
REPORT_FIELDS = {
    "schema_version", "model_sha256", "regularization_profile_sha256",
    "evaluation_corpus_sha256", "attack_implementation_sha256",
    "seed_policy_sha256", "metric_definition_sha256", "observations",
}
OBSERVATION_FIELDS = {
    "case_id", "threat_context", "attack_success", "task_quality",
}


class InsufficientData(RuntimeError):
    pass


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def verified_blob_bytes(
    path: Path, bundle: Path, identity: str,
) -> tuple[bytes, bytes]:
    artifact_bytes = path.read_bytes()
    bundle_bytes = bundle.read_bytes()
    if not artifact_bytes or not bundle_bytes:
        raise ValueError("signed artifact or bundle is empty")
    with tempfile.TemporaryDirectory(prefix="aidefend-attention-verify-") as directory:
        root = Path(directory)
        os.chmod(root, 0o700)
        artifact_snapshot = root / "artifact"
        bundle_snapshot = root / "bundle.json"
        artifact_snapshot.write_bytes(artifact_bytes)
        bundle_snapshot.write_bytes(bundle_bytes)
        os.chmod(artifact_snapshot, 0o400)
        os.chmod(bundle_snapshot, 0o400)
        subprocess.run([
            "cosign", "verify-blob", "--bundle", str(bundle_snapshot),
            "--certificate-identity", identity,
            "--certificate-oidc-issuer", OIDC_ISSUER, str(artifact_snapshot),
        ], check=True, capture_output=True, text=True, timeout=ATTENTION_ARTIFACT_VERIFY_TIMEOUT_SECONDS)
        if (
            not hmac.compare_digest(artifact_snapshot.read_bytes(), artifact_bytes)
            or not hmac.compare_digest(bundle_snapshot.read_bytes(), bundle_bytes)
        ):
            raise ValueError("verification snapshot changed")
    return artifact_bytes, bundle_bytes


def strict_json(raw: bytes, label: str) -> dict:
    def reject_duplicates(pairs: list[tuple[str, object]]) -> dict:
        result = {}
        for key, value in pairs:
            if key in result:
                raise ValueError(f"duplicate JSON key: {key}")
            result[key] = value
        return result

    try:
        value = json.loads(
            raw.decode("utf-8", errors="strict"),
            object_pairs_hook=reject_duplicates,
            parse_constant=lambda value: (_ for _ in ()).throw(
                ValueError(f"non-finite JSON number: {value}")
            ),
        )
    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
        raise ValueError(f"{label} is not strict UTF-8 JSON") from exc
    if not isinstance(value, dict):
        raise ValueError(f"{label} must be an object")
    return value


def finite(value: object, field: str) -> float:
    if isinstance(value, bool):
        raise ValueError(f"{field} must be numeric")
    number = float(value)
    if not math.isfinite(number):
        raise ValueError(f"{field} must be finite")
    return number


def load_policy(path: Path, bundle: Path) -> tuple[dict, str, str]:
    raw, verified_bundle_bytes = verified_blob_bytes(
        path, bundle, POLICY_IDENTITY
    )
    policy = strict_json(raw, "attention policy")
    if set(policy) != POLICY_FIELDS:
        raise ValueError("attention policy schema differs")
    if policy["schema_version"] != "aidefend.attention-robustness-policy.v2":
        raise ValueError("attention policy version is unsupported")
    if not isinstance(policy["policy_version"], str) or not policy["policy_version"]:
        raise ValueError("policy_version is required")
    contexts = policy["required_threat_contexts"]
    if not isinstance(contexts, list) or not contexts or len(contexts) != len(set(contexts)):
        raise ValueError("required_threat_contexts must be unique and non-empty")
    minimum_cases = policy["minimum_cases_per_context"]
    if isinstance(minimum_cases, bool) or not isinstance(minimum_cases, int) or minimum_cases <= 0:
        raise ValueError("minimum_cases_per_context must be a positive integer")
    improvement = finite(
        policy["minimum_robustness_improvement"],
        "minimum_robustness_improvement",
    )
    if not 0.0 <= improvement <= 1.0:
        raise ValueError("minimum_robustness_improvement must be in [0, 1]")
    for field in (
        "metric_definition_sha256", "baseline_profile_sha256",
        "candidate_profile_sha256", "evaluation_corpus_sha256",
        "attack_implementation_sha256", "seed_policy_sha256",
    ):
        value = policy[field]
        if not isinstance(value, str) or len(value) != 64:
            raise ValueError(f"{field} is not a SHA-256 value")
        int(value, 16)
    return (
        policy,
        hashlib.sha256(raw).hexdigest(),
        hashlib.sha256(verified_bundle_bytes).hexdigest(),
    )


def load_metric_definition(
    path: Path,
    bundle: Path,
    policy: dict,
) -> tuple[dict, str, str]:
    raw, verified_bundle_bytes = verified_blob_bytes(
        path, bundle, POLICY_IDENTITY
    )
    raw_sha256 = hashlib.sha256(raw).hexdigest()
    if raw_sha256 != policy["metric_definition_sha256"]:
        raise ValueError("metric definition digest differs from signed policy")
    metric = strict_json(raw, "attention metric definition")
    if set(metric) != METRIC_FIELDS:
        raise ValueError("metric definition schema differs")
    if (
        metric["schema_version"] != "aidefend.attention-metric-definition.v1"
        or metric["robustness_formula"] != "one_minus_attack_success_rate"
        or metric["attack_success_field"] != "attack_success"
    ):
        raise ValueError("metric definition is unsupported")
    task_metrics = metric["task_metrics"]
    if not isinstance(task_metrics, dict) or not task_metrics:
        raise ValueError("task metric definitions are required")
    for name, spec in task_metrics.items():
        if (
            not isinstance(name, str)
            or not name
            or not isinstance(spec, dict)
            or set(spec) != {"aggregation", "value_min", "value_max"}
        ):
            raise ValueError("task metric definition differs")
        if spec["aggregation"] != "mean":
            raise ValueError("only declared mean aggregation is implemented")
        low = finite(spec["value_min"], f"{name}.value_min")
        high = finite(spec["value_max"], f"{name}.value_max")
        if not low < high:
            raise ValueError(f"{name} range is invalid")
    if set(policy["task_quality_minimums"]) != set(task_metrics):
        raise ValueError("task-quality minimum keys differ from metric definition")
    if set(policy["maximum_task_quality_drop"]) != set(task_metrics):
        raise ValueError("task-quality drop keys differ from metric definition")
    for name, spec in task_metrics.items():
        low = finite(spec["value_min"], f"{name}.value_min")
        high = finite(spec["value_max"], f"{name}.value_max")
        minimum = finite(policy["task_quality_minimums"][name], f"{name}.minimum")
        maximum_drop = finite(
            policy["maximum_task_quality_drop"][name], f"{name}.maximum_drop"
        )
        if not low <= minimum <= high or not 0.0 <= maximum_drop <= high - low:
            raise ValueError(f"{name} policy limit is outside its signed range")
    return (
        metric,
        raw_sha256,
        hashlib.sha256(verified_bundle_bytes).hexdigest(),
    )

def load_report(
    path: Path,
    bundle: Path,
    expected_model_sha256: str,
    expected_profile_sha256: str,
    policy: dict,
    metric: dict,
) -> tuple[dict, str, str]:
    raw, verified_bundle_bytes = verified_blob_bytes(
        path, bundle, EVALUATOR_IDENTITY
    )
    report = strict_json(raw, "attention raw observations")
    if set(report) != REPORT_FIELDS:
        raise InsufficientData("raw evaluation report schema differs")
    if report["schema_version"] != "aidefend.attention-raw-observations.v1":
        raise InsufficientData("raw evaluation report version is unsupported")
    if report["model_sha256"] != expected_model_sha256:
        raise InsufficientData("raw report is not bound to actual model bytes")
    if report["regularization_profile_sha256"] != expected_profile_sha256:
        raise InsufficientData("regularization profile binding differs")
    if report["metric_definition_sha256"] != policy["metric_definition_sha256"]:
        raise InsufficientData("metric definition binding differs")
    for field in (
        "evaluation_corpus_sha256",
        "attack_implementation_sha256",
        "seed_policy_sha256",
    ):
        if report[field] != policy[field]:
            raise InsufficientData(
                f"raw report {field} differs from the signed policy"
            )
    observations = report["observations"]
    if not isinstance(observations, list) or not observations:
        raise InsufficientData("raw observations are missing")
    case_ids = []
    for row in observations:
        if not isinstance(row, dict) or set(row) != OBSERVATION_FIELDS:
            raise InsufficientData("raw observation schema differs")
        if not isinstance(row["case_id"], str) or not row["case_id"]:
            raise InsufficientData("case_id is missing")
        if row["threat_context"] not in policy["required_threat_contexts"]:
            raise InsufficientData("undeclared threat context is present")
        if not isinstance(row["attack_success"], bool):
            raise InsufficientData("attack_success must be a raw boolean")
        if (
            not isinstance(row["task_quality"], dict)
            or set(row["task_quality"]) != set(metric["task_metrics"])
        ):
            raise InsufficientData("task-quality observation schema differs")
        for name, value in row["task_quality"].items():
            number = finite(value, f"{name}.observation")
            spec = metric["task_metrics"][name]
            if not finite(spec["value_min"], name) <= number <= finite(
                spec["value_max"], name
            ):
                raise InsufficientData(
                    f"{name} observation is outside its metric range"
                )
        case_ids.append(row["case_id"])
    if len(case_ids) != len(set(case_ids)):
        raise InsufficientData("case IDs are duplicated")
    counts = Counter(row["threat_context"] for row in observations)
    expected_contexts = set(policy["required_threat_contexts"])
    if set(counts) != expected_contexts:
        raise InsufficientData("required threat-context coverage differs")
    if any(
        counts[name] < policy["minimum_cases_per_context"]
        for name in expected_contexts
    ):
        raise InsufficientData("threat-context case coverage is insufficient")
    return (
        report,
        hashlib.sha256(raw).hexdigest(),
        hashlib.sha256(verified_bundle_bytes).hexdigest(),
    )

def summarize(report: dict, metric: dict) -> tuple[float, dict[str, float]]:
    rows = report["observations"]
    robustness = 1.0 - sum(row["attack_success"] for row in rows) / len(rows)
    task_quality = {
        name: sum(float(row["task_quality"][name]) for row in rows) / len(rows)
        for name in metric["task_metrics"]
    }
    return robustness, task_quality


def gate(
    policy_path: Path,
    policy_bundle: Path,
    metric_path: Path,
    metric_bundle: Path,
    baseline_report_path: Path,
    baseline_report_bundle: Path,
    candidate_report_path: Path,
    candidate_report_bundle: Path,
    baseline_model_path: Path,
    candidate_model_path: Path,
) -> dict:
    baseline_model_bytes = baseline_model_path.read_bytes()
    candidate_model_bytes = candidate_model_path.read_bytes()
    if not baseline_model_bytes or not candidate_model_bytes:
        raise InsufficientData("baseline or candidate model artifact is empty")
    baseline_model_sha256 = hashlib.sha256(baseline_model_bytes).hexdigest()
    candidate_model_sha256 = hashlib.sha256(candidate_model_bytes).hexdigest()

    (
        policy,
        policy_sha256,
        policy_bundle_sha256,
    ) = load_policy(policy_path, policy_bundle)
    (
        metric,
        metric_sha256,
        metric_bundle_sha256,
    ) = load_metric_definition(metric_path, metric_bundle, policy)
    (
        baseline,
        baseline_report_sha256,
        baseline_report_bundle_sha256,
    ) = load_report(
        baseline_report_path,
        baseline_report_bundle,
        baseline_model_sha256,
        policy["baseline_profile_sha256"],
        policy,
        metric,
    )
    (
        candidate,
        candidate_report_sha256,
        candidate_report_bundle_sha256,
    ) = load_report(
        candidate_report_path,
        candidate_report_bundle,
        candidate_model_sha256,
        policy["candidate_profile_sha256"],
        policy,
        metric,
    )
    bindings = (
        "evaluation_corpus_sha256", "attack_implementation_sha256",
        "seed_policy_sha256", "metric_definition_sha256",
    )
    if any(baseline[field] != candidate[field] for field in bindings):
        raise InsufficientData("baseline and candidate evaluation bindings differ")
    baseline_population = [
        (row["case_id"], row["threat_context"]) for row in baseline["observations"]
    ]
    candidate_population = [
        (row["case_id"], row["threat_context"]) for row in candidate["observations"]
    ]
    if baseline_population != candidate_population:
        raise InsufficientData("baseline and candidate case/context populations differ")

    baseline_robustness, baseline_quality = summarize(baseline, metric)
    candidate_robustness, candidate_quality = summarize(candidate, metric)
    improvement = candidate_robustness - baseline_robustness
    failures = []
    if improvement < float(policy["minimum_robustness_improvement"]):
        failures.append("robustness_improvement")
    for name in metric["task_metrics"]:
        if candidate_quality[name] < float(policy["task_quality_minimums"][name]):
            failures.append(f"task_quality_minimum:{name}")
        if baseline_quality[name] - candidate_quality[name] > float(
            policy["maximum_task_quality_drop"][name]
        ):
            failures.append(f"task_quality_drop:{name}")

    return {
        "schema_version": "aidefend.attention-robustness-evaluation.v2",
        "status": "fail" if failures else "pass",
        "failures": failures,
        "policy_version": policy["policy_version"],
        "policy_sha256": policy_sha256,
        "policy_bundle_sha256": policy_bundle_sha256,
        "metric_definition_sha256": metric_sha256,
        "metric_definition_bundle_sha256": metric_bundle_sha256,
        "baseline_model_sha256": baseline_model_sha256,
        "candidate_model_sha256": candidate_model_sha256,
        "baseline_report_sha256": baseline_report_sha256,
        "baseline_report_bundle_sha256": baseline_report_bundle_sha256,
        "candidate_report_sha256": candidate_report_sha256,
        "candidate_report_bundle_sha256": candidate_report_bundle_sha256,
        "bindings": {field: candidate[field] for field in bindings},
        "baseline_robustness": baseline_robustness,
        "candidate_robustness": candidate_robustness,
        "robustness_improvement": improvement,
        "baseline_task_quality": baseline_quality,
        "candidate_task_quality": candidate_quality,
    }


def canonical(value: dict) -> bytes:
    return json.dumps(
        value, sort_keys=True, separators=(",", ":"), allow_nan=False
    ).encode()


def gate_with_status(**kwargs) -> dict:
    try:
        outcome = gate(**kwargs)
        outcome["input_evidence_state"] = "verified"
        return outcome
    except InsufficientData as exc:
        return {
            "schema_version": "aidefend.attention-robustness-evaluation.v2",
            "status": "insufficient_data",
            "failures": [],
            "reason": str(exc),
            "input_evidence_state": "unverified",
            "verified_input_digests": None,
        }
    except Exception as exc:
        return {
            "schema_version": "aidefend.attention-robustness-evaluation.v2",
            "status": "error",
            "failures": [],
            "reason": "attention gate input or dependency verification failed",
            "error_class": type(exc).__name__,
            "input_evidence_state": "unverified",
            "verified_input_digests": None,
        }


def write_exclusive(path: Path, payload: bytes) -> None:
    with path.open("xb") as handle:
        handle.write(payload)
        handle.flush()
        os.fsync(handle.fileno())


def gate_and_sign(
    *, verdict_path: Path, verdict_signature_path: Path, **gate_inputs,
) -> dict:
    if verdict_path.exists() or verdict_signature_path.exists():
        raise FileExistsError("verdict publish path already exists")
    outcome = gate_with_status(**gate_inputs)
    payload = canonical(outcome) + b"\n"
    expected_digest = hashlib.sha256(payload).hexdigest()

    with tempfile.TemporaryDirectory(
        prefix="aidefend-attention-verdict-"
    ) as directory:
        root = Path(directory)
        os.chmod(root, 0o700)
        payload_snapshot = root / "verdict.json"
        signature_snapshot = root / "verdict.sig"
        write_exclusive(payload_snapshot, payload)
        os.chmod(payload_snapshot, 0o400)
        if sha256_file(payload_snapshot) != expected_digest:
            raise RuntimeError("private canonical verdict snapshot differs")
        subprocess.run([
            "cosign", "sign-blob", "--yes",
            "--key", "env://ATTENTION_GATE_SIGNING_KEY",
            "--bundle", str(signature_snapshot),
            str(payload_snapshot),
        ], check=True, capture_output=True, text=True, timeout=ATTENTION_VERDICT_SIGN_TIMEOUT_SECONDS)
        signature_bytes = signature_snapshot.read_bytes()
        if not signature_bytes or sha256_file(payload_snapshot) != expected_digest:
            raise RuntimeError("private verdict changed during signing")
        subprocess.run([
            "cosign", "verify-blob", "--key", str(VERDICT_VERIFY_KEY),
            "--bundle", str(signature_snapshot), str(payload_snapshot),
        ], check=True, capture_output=True, text=True, timeout=ATTENTION_VERDICT_VERIFY_TIMEOUT_SECONDS)
        if (
            sha256_file(payload_snapshot) != expected_digest
            or not hmac.compare_digest(
                signature_snapshot.read_bytes(), signature_bytes
            )
        ):
            raise RuntimeError("private verdict or signature changed during verification")
        write_exclusive(verdict_signature_path, signature_bytes)
        write_exclusive(verdict_path, payload)
    if (
        sha256_file(verdict_path) != expected_digest
        or not hmac.compare_digest(
            verdict_signature_path.read_bytes(), signature_bytes
        )
    ):
        raise RuntimeError("published verdict pair differs from verified snapshots")
    return outcome

Evidence: The gate captures policy, metric definition, and raw reports into private read-only snapshots, verifies signatures over those snapshots, and parses only the verified snapshot bytes; hashes the actual baseline and candidate model bytes; rejects non-finite values; and derives robustness from signed per-case observations rather than trusting a report-supplied score. Both models must cover the exact same case/context population and all policy-required contexts. The signed policy, rather than either report, pins the exact evaluation-corpus, attack-implementation, and seed-policy digests; equality between two reports is never sufficient by itself.

Action: Release automation must call gate_and_sign, not the pure gate, so pass, fail, insufficient_data, and error all become canonical verdicts signed under an identity separate from training. Missing signatures, artifacts, cases, contexts, metrics, or digest bindings produces a signed negative verdict and blocks promotion. The presence of entropy regularization, noisy attention, RoPE, or an architecture label is never evidence by itself.

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-H-001: Adversarial Robustness Training | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version