AID-H-031.001: AI-Generated Change Provenance & Risk Labeling

Version 1.20261015 · Actionable sub-technique

Attach durable provenance and risk metadata to every AI-generated or AI-materially-modified artifact so downstream policy engines, reviewers, and promotion gates can treat those artifacts differently from purely human-authored changes. Labels should record who initiated the generation, which model or agent produced it, what repository or ticket context it used, the confidence or validation status, and whether the change touches privileged paths such as identity, secrets, infrastructure, deployment, payment, or destructive operations.

Pillars: infra, app

Lifecycle: building, validation

Scope boundary

Owns durable provenance and risk labels for AI-generated or AI-materially-modified development artifacts, including initiator, model or agent, context, validation status, and privileged-path impact. It emits routing facts but does not approve the change.

AID-H-031.004: AID-H-031.001 labels provenance and risk so the correct validation route can be selected; AID-H-031.004 consumes those labels and the complete evidence chain to authorize promotion. A provenance label is evidence, not approval.

Implementation guidance

AID-H-031.001-G001

Derive and sign AI-change provenance from pinned Git commits and a separately signed generation event.
Concept:

Derive the changed-file set from two pinned Git commit IDs, hash the exact Git blobs rather than a caller-maintained file list, and bind it to a separately signed generation event from the coding-agent platform. Risk labels are computed from paths, not accepted from the agent.

# File: ci/build_generated_change_provenance.py
from __future__ import annotations

import hashlib
import json
import math
import os
import re
import subprocess
from pathlib import Path


COMMIT_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
RISK_PREFIXES = {
    "identity": ("auth/", "identity/"),
    "secrets": ("secrets/",),
    "infra": ("infra/", "terraform/", "helm/"),
    "deploy": ("deploy/", ".github/workflows/"),
    "payments": ("payments/",),
    "destructive_ops": ("migrations/", "operations/destructive/"),
}


def required_git_timeout() -> float:
    value = float(os.environ["VERIFIED_H031_GIT_COMMAND_TIMEOUT_SECONDS"])
    if not math.isfinite(value) or value <= 0: raise ValueError("Git command timeout must be finite and positive")
    return value

GIT_COMMAND_TIMEOUT_SECONDS = required_git_timeout()

def git(*args: str) -> bytes:
    return subprocess.check_output(["git", *args], timeout=GIT_COMMAND_TIMEOUT_SECONDS)


def resolve_commit(value: str) -> str:
    if not COMMIT_RE.fullmatch(value):
        raise ValueError("commit must be one full SHA-1 or SHA-256 object ID")
    resolved = git("rev-parse", "--verify", value + "^{commit}").decode().strip()
    if resolved != value:
        raise ValueError("commit does not resolve exactly")
    return resolved


def changed_blobs(base: str, head: str) -> list[dict]:
    subprocess.run(["git", "merge-base", "--is-ancestor", base, head], check=True, timeout=GIT_COMMAND_TIMEOUT_SECONDS)
    fields = git("diff", "--name-status", "--no-renames", "-z", base, head).split(b"\0")
    if fields and fields[-1] == b"":
        fields.pop()
    if len(fields) % 2:
        raise ValueError("unexpected git name-status output")
    records = []
    for index in range(0, len(fields), 2):
        status = fields[index].decode("ascii")
        path = fields[index + 1].decode("utf-8", "strict")
        if status not in {"A", "M", "D", "T"} or not path or "\x00" in path:
            raise ValueError("unsupported change status or path")
        commit = base if status == "D" else head
        blob = git("show", f"{commit}:{path}")
        oid = git("rev-parse", f"{commit}:{path}").decode().strip()
        records.append({
            "path": path,
            "status": status,
            "git_blob_oid": oid,
            "content_sha256": hashlib.sha256(blob).hexdigest(),
        })
    return sorted(records, key=lambda item: item["path"])


def derived_risks(records: list[dict]) -> list[str]:
    paths = [item["path"] for item in records]
    return sorted(
        risk for risk, prefixes in RISK_PREFIXES.items()
        if any(path.startswith(prefix) for path in paths for prefix in prefixes)
    )


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


def build() -> dict:
    base = resolve_commit(os.environ["AIDEFEND_BASE_COMMIT"])
    head = resolve_commit(os.environ["AIDEFEND_HEAD_COMMIT"])
    event_path = Path(os.environ["AIDEFEND_SIGNED_GENERATION_EVENT"])
    event_raw = event_path.read_bytes()
    event = json.loads(event_raw.decode("utf-8", errors="strict"))
    if event.get("schema_version") != "aidefend.generation-event.v1":
        raise ValueError("generation event schema differs")
    if event.get("base_commit") != base or event.get("head_commit") != head:
        raise ValueError("generation event commit binding differs")
    records = changed_blobs(base, head)
    if not records:
        raise ValueError("change set is empty")
    change_set_sha256 = hashlib.sha256(canonical(records)).hexdigest()
    return {
        "schema_version": "aidefend.generated-change-provenance.v2",
        "generated_by": event["generated_by"],
        "generator_id": event["generator_id"],
        "model_id": event["model_id"],
        "generation_event_sha256": hashlib.sha256(event_raw).hexdigest(),
        "source_repo": event["source_repo"],
        "base_commit": base,
        "head_commit": head,
        "ticket_id": event["ticket_id"],
        "file_manifest": records,
        "change_set_sha256": change_set_sha256,
        "touched_risk_paths": derived_risks(records),
        "ci_workflow_identity": os.environ["AIDEFEND_CI_WORKFLOW_IDENTITY"],
        "ci_run_id": os.environ["AIDEFEND_CI_RUN_ID"],
    }


output = Path(".aidefend/generated-change.json")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(canonical(build()) + b"\n")
# Verify a private snapshot, then parse/hash that same event and sign the derived provenance.
SNAPSHOT_DIR=$(mktemp -d)
trap 'rm -rf -- "$SNAPSHOT_DIR"' EXIT
EVENT_SNAPSHOT="$SNAPSHOT_DIR/generation-event.json"
install -m 0600 -- "$AIDEFEND_SIGNED_GENERATION_EVENT" "$EVENT_SNAPSHOT"
cosign verify-blob --bundle generation-event.sigstore.json \
  --certificate-identity "$GENERATION_PLATFORM_IDENTITY" \
  --certificate-oidc-issuer "$OIDC_ISSUER" "$EVENT_SNAPSHOT"
AIDEFEND_SIGNED_GENERATION_EVENT="$EVENT_SNAPSHOT" python ci/build_generated_change_provenance.py
cosign sign-blob --yes --bundle .aidefend/generated-change.sigstore.json \
  .aidefend/generated-change.json

Action: Pin both signer identities in downstream policy and retain the event, provenance, bundles, and Git objects in append-only build evidence. Missing commits, omitted/unreadable blobs, signature failure, or an unbound generation event is insufficient_data and blocks routing.

Production implementation

At change creation, derive base/head commits and exact path/blob diff from Git, verify the separately signed generation event, bind model/provider, prompt/workflow and actor identities, classify policy risk and sign a closed provenance record without trusting author labels.

Independent verification

A separately credentialed verifier independently reads back or replays Git object graph, generation-event signature, change digest and signed provenance with amended history, mixed worktree, missing event, wrong model and signature fixtures, runs positive, negative, boundary, stale-policy, partial-population and dependency-failure fixtures, and reconciles the complete eligible population without trusting a component-supplied verdict.

AID-H-031.001-G002

Verify signed provenance, rehash the pinned Git change set, and derive a signed routing decision.
Concept:

Routing verifies the provenance and policy signatures, recomputes the exact Git change manifest, and derives risk from paths again. It produces required checks; it never accepts a text file claiming those checks completed.

# File: ci/route_generated_change.py
from __future__ import annotations

import hashlib
import json
import os
import subprocess

COMMAND_TIMEOUT_SECONDS = int(os.environ["VERIFIED_COMMAND_TIMEOUT_SECONDS"])
from pathlib import Path

from ci.build_generated_change_provenance import changed_blobs, derived_risks


def verify_blob(path: Path, bundle: Path, identity: str, issuer: str) -> None:
    subprocess.run([
        "cosign", "verify-blob", "--bundle", str(bundle),
        "--certificate-identity", identity,
        "--certificate-oidc-issuer", issuer, str(path),
    ], check=True, timeout=COMMAND_TIMEOUT_SECONDS)


def route(provenance: dict, policy: dict) -> dict:
    recomputed = changed_blobs(provenance["base_commit"], provenance["head_commit"])
    digest = hashlib.sha256(
        json.dumps(recomputed, sort_keys=True, separators=(",", ":")).encode()
    ).hexdigest()
    if recomputed != provenance["file_manifest"] or digest != provenance["change_set_sha256"]:
        raise PermissionError("provenance file-manifest binding differs")
    risks = derived_risks(recomputed)
    if risks != provenance["touched_risk_paths"]:
        raise PermissionError("provenance risk labels differ")

    matching = [
        tier for tier in policy["tiers"]
        if set(risks).issubset(set(tier["allowed_risk_paths"]))
    ]
    if not matching:
        raise PermissionError("no routing tier covers the derived risks")
    selected = min(matching, key=lambda item: int(item["priority"]))
    return {
        "schema_version": "aidefend.generated-change-routing.v2",
        "provenance_sha256": hashlib.sha256(
            json.dumps(provenance, sort_keys=True, separators=(",", ":")).encode()
        ).hexdigest(),
        "policy_version": policy["policy_version"],
        "selected_tier": selected["id"],
        "required_checks": sorted(set(selected["required_checks"])),
        "required_review_roles": sorted(set(selected["required_review_roles"])),
        "base_commit": provenance["base_commit"],
        "head_commit": provenance["head_commit"],
        "change_set_sha256": provenance["change_set_sha256"],
    }

Action: Verify both bundles before parsing, require exact policy/provenance schemas, and sign the routing decision under the admission-policy identity. AID-H-031.002 and AID-H-031.003 consumes this decision and independently verifies actual check attestations; check names in a caller file never count as completion.

Production implementation

At pull-request or CI admission, verify provenance and generation-event signatures, recompute exact Git object/diff digests, reject extra/untracked or changed paths, derive risk from signed policy and emit a separate signed routing decision for required gates.

Independent verification

A separately credentialed verifier independently reads back or replays signatures, commits, diff/path population and route decision with forged, stale, amended, partial, extra-path, policy-drift and command-failure fixtures, runs positive, negative, boundary, stale-policy, partial-population and dependency-failure fixtures, and reconciles the complete eligible population without trusting a component-supplied verdict.

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-031.001: AI-Generated Change Provenance & Risk Labeling | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version