AID-H-007.006: Post-Training Optimization & Format-Conversion Safety Regression
Pillars: model, infra, app
Lifecycle: building, validation
Scope boundary
Owns source-to-candidate lineage and pre/post behavioral regression for quantization, pruning, distillation, merges, format conversion, runtime engine compilation, and serving-time model/interface pairings derived from an approved source.
AID-H-007.005: AID-H-007.006 evaluates safety change from post-training optimization, conversion, or derived serving artifacts; AID-H-007.005 evaluates safety erosion from fine-tuning and behavioral customization. Distillation belongs here when it creates a deployment-derived student from an approved source, even though its derivation uses training.
AID-H-003.006: AID-H-007.006 verifies transformation-specific lineage and behavioral delta; AID-H-003.006 owns the general model SBOM and provenance attestation. The transformation gate adds source-to-candidate evidence without becoming the canonical composition record.
Implementation guidance
- AID-H-007.006-G001: Bind each transformed candidate to approved source bytes and transformation settings, compare the source and candidate on the same signed task and security suite, and fail promotion on missing evidence or excessive regression.
- AID-H-007.006-G002: Treat a model-interface pairing change as a release candidate and run the same signed functional and security fixtures against the approved and candidate tuples.
AID-H-007.006-G001
Concept:
A converted, optimized, distilled, merged, or paired model is a new deployable artifact. Build lineage from the exact bytes consumed and produced by the derivation job, independently verify the approved source manifest before reading it, and compare source and candidate results over one actual case-set digest. Never infer lineage from a model name or let aggregate quality hide a security-category regression.
Step 1: Supply one concrete signed transformation policy
The separately administered policy authority publishes transform/post-transform-policy.json and its Cosign verification bundle with concrete integer sample floors and finite per-category regression ceilings. This is an input artifact, not a template. The gate in Step 2 verifies it under the baked-in policy key, enforces the exact schema, rejects nonnumeric or out-of-range values, checks the policy against stricter local ceilings, and binds source/candidate artifacts, toolchain, parameters, case set, and independently signed evaluations. Allowed transform types are quantization, pruning, distillation, model_merge, onnx_export, gguf_conversion, tensorrt_engine, and speculative_draft_pair; trust anchors for policy, source manifest, evaluator evidence, and the final report remain distinct.
Step 2: Verify policy, source approval, independent evaluations, and raw evidence before enforcing the delta gate
# File: transform/post_transform_gate.py
from __future__ import annotations
import os
import hashlib
import json
import math
import re
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
def _required_unit_interval(name: str) -> float:
value = float(os.environ[name])
if not math.isfinite(value) or not 0 < value <= 1:
raise RuntimeError(f"{name} must be in the interval (0, 1]")
return value
TASK_QUALITY_MAX_REGRESSION = _required_unit_interval("VERIFIED_H007_TASK_QUALITY_MAX_REGRESSION")
HARMFUL_CONTENT_MAX_REGRESSION = _required_unit_interval("VERIFIED_H007_HARMFUL_CONTENT_MAX_REGRESSION")
JAILBREAK_MAX_REGRESSION = _required_unit_interval("VERIFIED_H007_JAILBREAK_MAX_REGRESSION")
PROMPT_INJECTION_MAX_REGRESSION = _required_unit_interval("VERIFIED_H007_PROMPT_INJECTION_MAX_REGRESSION")
SENSITIVE_DATA_MAX_REGRESSION = _required_unit_interval("VERIFIED_H007_SENSITIVE_DATA_MAX_REGRESSION")
TOOL_POLICY_MAX_REGRESSION = _required_unit_interval("VERIFIED_H007_TOOL_POLICY_MAX_REGRESSION")
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
LOCAL_MINIMUM_SAMPLES_PER_CATEGORY = _required_positive_int("VERIFIED_H007_TRANSFORM_MINIMUM_SAMPLES_PER_CATEGORY")
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 finite and positive")
return value
TRANSFORM_SIGNATURE_VERIFY_TIMEOUT_SECONDS = _required_positive_seconds("VERIFIED_H007_TRANSFORM_SIGNATURE_VERIFY_TIMEOUT_SECONDS")
POLICY_SCHEMA = "aidefend.post_transform_policy.v1"
EVALUATION_SCHEMA = "aidefend.transform_eval.v1"
POLICY_PUBLIC_KEY = "keys/post-transform-policy.pub"
SOURCE_MANIFEST_PUBLIC_KEY = "keys/model-source.pub"
EVALUATION_PUBLIC_KEY = "keys/independent-model-evaluation.pub"
LOCAL_REQUIRED_CATEGORY_RULES = {
"task_quality": ("success_rate", "higher_is_better", TASK_QUALITY_MAX_REGRESSION),
"harmful_content": ("violation_rate", "lower_is_better", HARMFUL_CONTENT_MAX_REGRESSION),
"jailbreak": ("attack_success_rate", "lower_is_better", JAILBREAK_MAX_REGRESSION),
"prompt_injection": ("attack_success_rate", "lower_is_better", PROMPT_INJECTION_MAX_REGRESSION),
"sensitive_data": ("leakage_rate", "lower_is_better", SENSITIVE_DATA_MAX_REGRESSION),
"tool_policy": ("violation_rate", "lower_is_better", TOOL_POLICY_MAX_REGRESSION),
}
ALLOWED_TRANSFORMS = {
"quantization", "pruning", "distillation", "model_merge",
"onnx_export", "gguf_conversion", "tensorrt_engine", "speculative_draft_pair",
}
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
def load(raw: bytes, label: str) -> dict:
def unique_pairs(pairs):
value = {}
for key, item in pairs:
if key in value:
raise ValueError(f"duplicate {label} key: {key}")
value[key] = item
return value
def reject_constant(value):
raise ValueError(f"non-finite {label} number: {value}")
value = json.loads(
raw.decode("utf-8", errors="strict"),
object_pairs_hook=unique_pairs, parse_constant=reject_constant,
)
if not isinstance(value, dict):
raise ValueError(f"JSON object required: {label}")
return value
def digest(path: str) -> str:
h = hashlib.sha256()
with Path(path).open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def digest_bytes(raw: bytes) -> str:
return hashlib.sha256(raw).hexdigest()
def require_nonempty_string(value: object, field: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(field + " must be a non-empty string")
return value
def require_sha256(value: object, field: str) -> str:
normalized = require_nonempty_string(value, field).removeprefix("sha256:")
if not SHA256_RE.fullmatch(normalized):
raise ValueError(field + " must be one lowercase SHA-256 digest")
return normalized
def require_integer(value: object, field: str, minimum: int = 0) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
raise ValueError(f"{field} must be an integer >= {minimum}")
return value
def require_rate(value: object, field: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(field + " must be numeric")
result = float(value)
if not math.isfinite(result) or not 0.0 <= result <= 1.0:
raise ValueError(field + " must be finite and within [0,1]")
return result
def verify_signed_blob(path: str, bundle: str, public_key: str) -> tuple[bytes, bytes]:
payload_raw = Path(path).read_bytes()
bundle_raw = Path(bundle).read_bytes()
public_key_raw = Path(public_key).read_bytes()
if not payload_raw or not bundle_raw or not public_key_raw:
raise ValueError("signed payload, bundle, and trust key must be non-empty")
with tempfile.TemporaryDirectory(prefix="post-transform-input-") as directory:
root = Path(directory)
os.chmod(root, 0o700)
payload_snapshot = root / "payload.json"
bundle_snapshot = root / "bundle.sigstore.json"
public_key_snapshot = root / "verification.pub"
captured = (
(payload_snapshot, payload_raw),
(bundle_snapshot, bundle_raw),
(public_key_snapshot, public_key_raw),
)
for target, raw in captured:
target.write_bytes(raw)
os.chmod(target, 0o400)
subprocess.run([
"cosign", "verify-blob", "--key", str(public_key_snapshot),
"--bundle", str(bundle_snapshot), str(payload_snapshot),
], check=True, capture_output=True, text=True,
timeout=TRANSFORM_SIGNATURE_VERIFY_TIMEOUT_SECONDS)
if any(target.read_bytes() != raw for target, raw in captured):
raise ValueError("signed-input verification snapshot changed")
return payload_raw, bundle_raw
def validate_policy(policy: dict) -> None:
if policy.get("schema_version") != POLICY_SCHEMA:
raise ValueError("unsupported post-transform policy schema")
if policy.get("transform_type") not in ALLOWED_TRANSFORMS:
raise ValueError("unapproved transform_type")
require_nonempty_string(policy.get("transform_id"), "transform_id")
require_nonempty_string(policy.get("policy_version"), "policy_version")
minimum_n = require_integer(
policy.get("minimum_samples_per_category"),
"minimum_samples_per_category",
minimum=LOCAL_MINIMUM_SAMPLES_PER_CATEGORY,
)
toolchain = policy.get("toolchain")
if not isinstance(toolchain, list) or not toolchain:
raise ValueError("toolchain name and version are required")
for index, tool in enumerate(toolchain):
if not isinstance(tool, dict):
raise ValueError(f"toolchain[{index}] must be an object")
require_nonempty_string(tool.get("name"), f"toolchain[{index}].name")
require_nonempty_string(tool.get("version"), f"toolchain[{index}].version")
categories = policy.get("required_categories")
if not isinstance(categories, dict) or not categories:
raise ValueError("required_categories cannot be empty")
for category, rule in categories.items():
require_nonempty_string(category, "category")
if not isinstance(rule, dict):
raise ValueError("category rule must be an object: " + category)
require_nonempty_string(rule.get("metric"), category + ".metric")
if rule.get("direction") not in {"lower_is_better", "higher_is_better"}:
raise ValueError("unknown metric direction for " + category)
require_rate(rule.get("max_regression"), category + ".max_regression")
for category, local_rule in LOCAL_REQUIRED_CATEGORY_RULES.items():
rule = categories.get(category)
if not isinstance(rule, dict):
raise ValueError("locally required category is missing: " + category)
expected_metric, expected_direction, maximum_ceiling = local_rule
if rule.get("metric") != expected_metric:
raise ValueError("metric differs from local profile: " + category)
if rule.get("direction") != expected_direction:
raise ValueError("direction differs from local profile: " + category)
configured_maximum = require_rate(
rule.get("max_regression"), category + ".max_regression"
)
if configured_maximum > maximum_ceiling:
raise ValueError(
f"{category}.max_regression exceeds local ceiling {maximum_ceiling}"
)
if minimum_n < LOCAL_MINIMUM_SAMPLES_PER_CATEGORY:
raise ValueError("minimum sample count is below the local floor")
def validate_evaluation(name: str, evaluation: dict, policy: dict) -> None:
if evaluation.get("schema_version") != EVALUATION_SCHEMA:
raise ValueError(name + " evaluation schema is unsupported")
require_sha256(evaluation.get("artifact_sha256"), name + ".artifact_sha256")
require_sha256(evaluation.get("case_set_sha256"), name + ".case_set_sha256")
require_nonempty_string(evaluation.get("suite_id"), name + ".suite_id")
categories = evaluation.get("categories")
if not isinstance(categories, dict):
raise ValueError(name + ".categories must be an object")
for category, rule in policy["required_categories"].items():
result = categories.get(category)
if result is None:
continue
if not isinstance(result, dict):
raise ValueError(f"{name}.{category} must be an object")
require_integer(result.get("n"), f"{name}.{category}.n")
metric = rule["metric"]
if metric in result:
require_rate(result[metric], f"{name}.{category}.{metric}")
def run(policy_path: str, policy_signature_path: str) -> dict:
# The policy trust anchor is baked into the gate image and is not policy-controlled.
policy_raw, policy_bundle_raw = verify_signed_blob(
policy_path, policy_signature_path, POLICY_PUBLIC_KEY
)
policy = load(policy_raw, "post-transform policy")
validate_policy(policy)
source_manifest_path = require_nonempty_string(
policy.get("approved_source_manifest"), "approved_source_manifest"
)
source_manifest_raw, source_manifest_bundle_raw = verify_signed_blob(
source_manifest_path,
require_nonempty_string(
policy.get("approved_source_manifest_signature"),
"approved_source_manifest_signature",
),
SOURCE_MANIFEST_PUBLIC_KEY,
)
source_manifest = load(source_manifest_raw, "approved source manifest")
case_set_path = require_nonempty_string(policy.get("case_set_path"), "case_set_path")
if Path(case_set_path).stat().st_size == 0:
raise ValueError("case set is empty")
source_eval_path = require_nonempty_string(
policy.get("source_evaluation_path"), "source_evaluation_path"
)
candidate_eval_path = require_nonempty_string(
policy.get("candidate_evaluation_path"), "candidate_evaluation_path"
)
source_eval_signature = require_nonempty_string(
policy.get("source_evaluation_signature"), "source_evaluation_signature"
)
candidate_eval_signature = require_nonempty_string(
policy.get("candidate_evaluation_signature"), "candidate_evaluation_signature"
)
# Evaluation signatures come from an independently administered workflow key,
# distinct from policy, source-manifest, and transform-report signing keys.
source_eval_raw, source_eval_bundle_raw = verify_signed_blob(
source_eval_path, source_eval_signature, EVALUATION_PUBLIC_KEY
)
candidate_eval_raw, candidate_eval_bundle_raw = verify_signed_blob(
candidate_eval_path, candidate_eval_signature, EVALUATION_PUBLIC_KEY
)
baseline = load(source_eval_raw, "source evaluation")
candidate = load(candidate_eval_raw, "candidate evaluation")
validate_evaluation("source", baseline, policy)
validate_evaluation("candidate", candidate, policy)
source_sha = digest(policy["source_artifact"])
candidate_sha = digest(policy["candidate_artifact"])
case_set_sha = digest(case_set_path)
approved_sha = require_sha256(source_manifest.get("artifactDigest"), "artifactDigest")
failures: list[dict] = []
insufficient: list[dict] = []
if source_sha != approved_sha:
failures.append({"issue": "source_digest_not_approved"})
if require_sha256(baseline["artifact_sha256"], "source.artifact_sha256") != source_sha:
failures.append({"issue": "baseline_not_bound_to_source"})
if require_sha256(candidate["artifact_sha256"], "candidate.artifact_sha256") != candidate_sha:
failures.append({"issue": "candidate_not_bound_to_output"})
if baseline["suite_id"] != candidate["suite_id"]:
failures.append({"issue": "suite_id_mismatch"})
if require_sha256(baseline["case_set_sha256"], "source.case_set_sha256") != case_set_sha:
failures.append({"issue": "source_evaluation_not_bound_to_case_set"})
if require_sha256(candidate["case_set_sha256"], "candidate.case_set_sha256") != case_set_sha:
failures.append({"issue": "candidate_evaluation_not_bound_to_case_set"})
draft_path = policy.get("draft_model_artifact")
draft_sha = None
if policy["transform_type"] == "speculative_draft_pair":
draft_path = require_nonempty_string(draft_path, "draft_model_artifact")
draft_sha = digest(draft_path)
reported_draft = require_sha256(
candidate.get("draft_model_sha256"), "candidate.draft_model_sha256"
)
if reported_draft != draft_sha:
failures.append({"issue": "candidate_not_bound_to_draft_model"})
elif draft_path is not None or candidate.get("draft_model_sha256") is not None:
raise ValueError("draft model fields are allowed only for speculative_draft_pair")
findings = []
minimum_n = policy["minimum_samples_per_category"]
for category, rule in policy["required_categories"].items():
base = baseline.get("categories", {}).get(category)
cand = candidate.get("categories", {}).get(category)
if base is None or cand is None:
insufficient.append({"category": category, "issue": "missing_category"})
continue
base_n = require_integer(base.get("n"), f"source.{category}.n")
candidate_n = require_integer(cand.get("n"), f"candidate.{category}.n")
if base_n < minimum_n or candidate_n < minimum_n:
insufficient.append({
"category": category, "issue": "insufficient_samples",
"baseline_n": base_n, "candidate_n": candidate_n,
"minimum": minimum_n,
})
continue
metric = rule["metric"]
if metric not in base or metric not in cand:
insufficient.append({"category": category, "issue": "missing_metric", "metric": metric})
continue
base_value = require_rate(base[metric], f"source.{category}.{metric}")
candidate_value = require_rate(cand[metric], f"candidate.{category}.{metric}")
maximum_regression = require_rate(
rule["max_regression"], category + ".max_regression"
)
regression = (
candidate_value - base_value
if rule["direction"] == "lower_is_better"
else base_value - candidate_value
)
passed = regression <= maximum_regression
finding = {
"category": category, "metric": metric, "baseline": base_value,
"candidate": candidate_value, "regression": round(regression, 6),
"maximum_regression": maximum_regression, "passed": passed,
}
findings.append(finding)
if not passed:
failures.append(finding)
evidence_digests = {
"policy_sha256": digest_bytes(policy_raw),
"policy_signature_sha256": digest_bytes(policy_bundle_raw),
"approved_source_manifest_sha256": digest_bytes(source_manifest_raw),
"approved_source_manifest_signature_sha256": digest_bytes(
source_manifest_bundle_raw
),
"case_set_sha256": case_set_sha,
"source_evaluation_sha256": digest_bytes(source_eval_raw),
"source_evaluation_signature_sha256": digest_bytes(source_eval_bundle_raw),
"candidate_evaluation_sha256": digest_bytes(candidate_eval_raw),
"candidate_evaluation_signature_sha256": digest_bytes(candidate_eval_bundle_raw),
"source_artifact_sha256": source_sha,
"candidate_artifact_sha256": candidate_sha,
"draft_model_artifact_sha256": draft_sha,
}
lineage = {
"transform_id": policy["transform_id"],
"transform_type": policy["transform_type"],
"policy_signature_verified": True,
"approved_source_signature_verified": True,
"independent_evaluation_signatures_verified": True,
"toolchain": policy["toolchain"],
"parameters": policy.get("parameters", {}),
}
status = "insufficient_data" if insufficient else ("block" if failures else "pass")
return {
"schema_version": "aidefend.post_transform_gate_report.v1",
"status": status,
"policy_version": policy["policy_version"],
"lineage": lineage,
"suite_id": candidate["suite_id"],
"evidence_digests": evidence_digests,
"findings": findings,
"insufficient_populations": insufficient,
"failures": failures,
"generated_at": datetime.now(timezone.utc).isoformat(),
}
if __name__ == "__main__":
output = Path("artifacts/post-transform-gate-report.json")
output.parent.mkdir(parents=True, exist_ok=True)
report = run(
"transform/post-transform-policy.json",
"transform/post-transform-policy.sig",
)
output.write_text(
json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
raise SystemExit(0 if report["status"] == "pass" else 1)
Step 3: Sign lineage-bearing evidence and bind it to promotion
set -euo pipefail
python transform/post_transform_gate.py
cosign sign-blob --yes --bundle artifacts/post-transform-gate-report.sigstore.json artifacts/post-transform-gate-report.json
# The independent promotion job obtains these exact values from its deployment-controlled policy.
test -n "$TRANSFORM_WORKFLOW_IDENTITY"
test -n "$CI_OIDC_ISSUER"
cosign verify-blob --bundle artifacts/post-transform-gate-report.sigstore.json --certificate-identity "$TRANSFORM_WORKFLOW_IDENTITY" --certificate-oidc-issuer "$CI_OIDC_ISSUER" artifacts/post-transform-gate-report.json
Evidence and freshness: Produce a fresh report for every candidate digest and every change to converter, distillation job, optimization parameters, serving runtime, case set, or draft-model pairing. Retain the signed policy, signed source manifest, actual case set, both independently signed raw evaluation files, artifacts, report and Sigstore bundle with the release record. An independent promotion job verifies each signed policy/source/evaluation input and the report bundle against its distinct trust anchor, re-hashes every file listed in evidence_digests, re-runs the gate from those exact bytes, and confirms the deployed candidate and any draft model match the report.
Action: Promotion stops on block, insufficient_data, invalid schema, a policy below local sample floors or above local regression ceilings, non-finite metrics, or any signature/digest mismatch; aggregate quality improvements never override an unmeasured required population or a security-category regression.
AID-H-007.006-G002
Build one candidate tuple
Create a canonical manifest containing SHA-256 digests for the model, tokenizer, special-token map, chat template, generation configuration, tool parser, reasoning parser, runtime image, and explicit launch arguments. Sign it and bind it to the deployed composition in AID-M-001.002. A parser name or model repository revision alone is not a complete pairing identity.
Run matched regression
Execute the approved tuple and candidate tuple on the same signed case population. Include message-role separation, empty and long conversations, Unicode boundaries, stop-token collisions, structured output, no-tool and multi-tool calls, malformed tool syntax, reasoning-only and reasoning-plus-tool responses, streaming splits at every parser boundary, prompt injection, jailbreak, leakage, and harmful-content cases. Record raw generated bytes or token IDs in a restricted evaluator store and compare the final visible content, reasoning field, tool-call order/IDs/arguments, finish reason, and policy result. Do not expose raw reasoning in ordinary evidence.
Promotion result
Block promotion on any unapproved field change, parser ambiguity, lost or duplicated content, role/token boundary regression, safety regression beyond signed policy, missing case, mismatched tuple digest, or incomplete replica readback. If the architecture has no separate parser or template, record that component as NOT_APPLICABLE in the signed composition rather than inventing a file. Independently replay the complete population and retain both tuple manifests, case-set digest, raw-result digests, normalized comparisons, policy, and signed decision.
Open-source tools
- PyTorch
- Hugging Face Optimum
- ONNX Runtime
- llama.cpp
- mergekit
- MLflow
- Cosign
- Inspect AI
- garak
- promptfoo
- Intel Neural Compressor
- NVIDIA Model Optimizer
Commercial / hosted tools
- NVIDIA TensorRT
- Amazon SageMaker Neo
- Azure Machine Learning
- Google Vertex AI
- Databricks Mosaic AI
Threat mappings
MITRE ATLAS
- AML.T0010 AI Supply Chain Compromise
- AML.T0011 User Execution
- AML.T0011.000 User Execution: Unsafe AI Artifacts
- AML.T0018.003 Manipulate AI Model: Modify Prompt Construction Logic
- AML.T0010.003 AI Supply Chain Compromise: Model
- AML.T0018 Manipulate AI Model
- AML.T0031 Erode AI Model Integrity
MAESTRO
- Supply Chain Attacks (Cross-Layer) (signed transformation lineage and regression gates reject tampered derived models)
OWASP LLM Top 10 2026
- LLM01:2026 Prompt Injection
- LLM02:2026 Sensitive Information Disclosure
- LLM03:2026 Excessive Agency (post-conversion tool-policy regression tests block artifacts with expanded behavior, while runtime authority remains separately enforced)
- LLM04:2026 Supply Chain
- LLM05:2026 Data and Model Poisoning
OWASP ML Top 10 2023
- ML06:2023 AI Supply Chain Attacks
- ML10:2023 Model Poisoning
OWASP Top 10 for Agentic Applications 2026
- ASI04:2026 Agentic Supply Chain Vulnerabilities (derived model artifacts are gated before agent deployment)
NIST Adversarial Machine Learning 2025
- NISTAML.026 Model Poisoning (Integrity)
- NISTAML.051 Model Poisoning (Supply Chain)
Cisco Integrated AI Security and Safety Framework
- AISubtech-1.1.4 Token Exploitation
- AISubtech-4.3.1 Schema Inconsistencies
- AITech-1.1 Direct Prompt Injection and Jailbreak (Prompt-based Manipulation)
- AITech-1.2 Indirect Prompt Injection
- AITech-12.1 Tool Exploitation
- AITech-15.1 Harmful Content
- AITech-4.2 Context Boundary Attacks
- AITech-4.3 Protocol Manipulation
- AITech-8.2 Data Exfiltration / Exposure
Google Secure AI Framework 2.0 - Risks
- MST: Model Source Tampering
Databricks AI Security Framework 3.0
- Model 7.3: ML Supply chain vulnerabilities
- Evaluation 6.2: Insufficient evaluation data
- Algorithms 5.1: Lack of tracking and reproducibility of experiments