AID-D-001.004: Active Prompt Integrity Check (Canary Tokens)
Pillars: app
Lifecycle: operation
Scope boundary
Owns single-use prompt-integrity challenge issuance and evaluation as a heuristic finding for prompt override, response-format breakage, or challenge leakage. The model-visible challenge is not a secret, signature, authorization proof, or guarantee of instruction compliance.
AID-H-006.001: AID-D-001.004 reports whether the isolated canary field is missing, changed, duplicated, expired, or leaked; AID-H-006.001 enforces the required output schema and rejects structurally invalid output. A returned canary is only a heuristic integrity signal, while schema enforcement is the deterministic release boundary.
AID-D-001.006: AID-D-001.004 creates a request-scoped integrity finding; AID-D-001.006 correlates gate and detector decisions over repeated requests, sessions, and identities. The canary checks one execution, while correlation identifies systematic probing or cumulative risk.
Implementation guidance
- AID-D-001.004-G001: Issue a per-request prompt-integrity challenge and evaluate the isolated structured response field as a heuristic finding.
AID-D-001.004-G001
Detector boundary
Use this detector only on model and route versions whose signed capability profile supports strict structured output with a backend-only field. A returned challenge is a heuristic continuity signal, not cryptographic attestation: the model sees the value and an attacker may preserve or copy it. The detector emits finding_status; a separate Hardening control decides whether to block, retry, downgrade, or review.
Issue and verify a single-use challenge
from __future__ import annotations
# File: detection/prompt_integrity_challenge.py
import hashlib
import hmac
import json
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Literal
from uuid import UUID
ControlStatus = Literal["PASS", "FAIL", "INSUFFICIENT_DATA", "NOT_APPLICABLE", "ERROR"]
FindingStatus = Literal["FINDING", "NO_FINDING", "UNKNOWN"]
@dataclass(frozen=True)
class Challenge:
request_id: str
value: str
value_sha256: str
issued_at: str
expires_at: str
@dataclass(frozen=True)
class Result:
schema_version: str
control: str
request_id: str
control_status: ControlStatus
finding_status: FindingStatus
reason: str
challenge_sha256: str
response_sha256: str
policy_digest: str
capability_digest: str
def issue(request_id: str, ttl_seconds: int, now: datetime | None = None) -> Challenge:
UUID(request_id)
if ttl_seconds < 1:
raise ValueError("ttl must come from signed policy and be positive")
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
value = secrets.token_urlsafe(24)
return Challenge(
request_id=request_id,
value=value,
value_sha256=hashlib.sha256(value.encode("utf-8")).hexdigest(),
issued_at=current.isoformat(),
expires_at=(current + timedelta(seconds=ttl_seconds)).isoformat(),
)
def system_fragment(challenge: Challenge) -> str:
return (
"Return exactly two JSON fields: answer and _integrity_challenge. "
"Set _integrity_challenge to this single-use value: "
+ challenge.value
+ ". Never place the value inside answer."
)
def evaluate(
response_bytes: bytes,
challenge: Challenge,
*,
policy_digest: str,
capability_digest: str,
now: datetime | None = None,
) -> Result:
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
response_sha256 = hashlib.sha256(response_bytes).hexdigest()
expires = datetime.fromisoformat(challenge.expires_at)
try:
parsed = json.loads(response_bytes.decode("utf-8", errors="strict"))
exact_schema = (
isinstance(parsed, dict)
and set(parsed) == {"answer", "_integrity_challenge"}
and isinstance(parsed.get("answer"), str)
and isinstance(parsed.get("_integrity_challenge"), str)
)
leaked = exact_schema and challenge.value in parsed["answer"]
matched = exact_schema and hmac.compare_digest(
parsed["_integrity_challenge"], challenge.value
)
if current > expires:
finding, reason = "FINDING", "expired challenge returned"
elif not exact_schema:
finding, reason = "FINDING", "structured response contract broken"
elif leaked:
finding, reason = "FINDING", "challenge leaked into visible answer"
elif not matched:
finding, reason = "FINDING", "challenge missing or mismatched"
else:
finding, reason = "NO_FINDING", "challenge matched and stayed isolated"
return Result(
schema_version="aidefend.d001004-result.v1",
control="AID-D-001.004",
request_id=challenge.request_id,
control_status="PASS",
finding_status=finding,
reason=reason,
challenge_sha256=challenge.value_sha256,
response_sha256=response_sha256,
policy_digest=policy_digest,
capability_digest=capability_digest,
)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
return Result(
schema_version="aidefend.d001004-result.v1",
control="AID-D-001.004",
request_id=challenge.request_id,
control_status="PASS",
finding_status="FINDING",
reason="response was not valid UTF-8 JSON: " + type(exc).__name__,
challenge_sha256=challenge.value_sha256,
response_sha256=response_sha256,
policy_digest=policy_digest,
capability_digest=capability_digest,
)Verify complete coverage
Bind request IDs, issued challenges, response IDs, and terminal outcomes in a source-authenticated completeness manifest; duplicate, missing, timed-out, or uncorrelated requests remain in the denominator.
Verify safely
An independent verifier replays signed benign, direct-injection, indirect-injection, copied-challenge, missing-field, leaked-field, invalid-JSON, timeout, duplicate, and unsupported-capability fixtures through the real gateway path, reads evidence back, and compares canonical result digests.
Open-source tools
- Pydantic (canary-response schema validation; canary issuance and comparison remain application-owned)
- LangChain
- LiteLLM
- OpenAI Python SDK
Threat mappings
MITRE ATLAS
- AML.T0051 LLM Prompt Injection
- AML.T0051.000 LLM Prompt Injection: Direct
- AML.T0051.001 LLM Prompt Injection: Indirect
- AML.T0051.002 LLM Prompt Injection: Triggered
- AML.T0054 LLM Jailbreak
- AML.T0056 Extract LLM System Prompt (a leaked challenge value is a system-prompt disclosure signal)
MAESTRO
- Reprogramming Attacks (L1)
- Evasion of Security AI Agents (L6)
- Agent Goal Manipulation (L7) (canary tokens detect if agent goals have been manipulated via prompt override)
- Input Validation Attacks (L3) (canary tokens validate prompt integrity against injection)
OWASP LLM Top 10 2026
- LLM01:2026 Prompt Injection (single-use prompt-integrity challenges expose override or format-breakage signals)
- LLM08:2026 Hidden Context Exposure
OWASP ML Top 10 2023
- ML01:2023 Input Manipulation Attack
OWASP Top 10 for Agentic Applications 2026
- N/A
NIST Adversarial Machine Learning 2025
- NISTAML.018 Prompt Injection
- NISTAML.015 Indirect Prompt Injection (canary detects indirect injection that overrides system prompt)
- NISTAML.035 Prompt Extraction (canary tokens detect prompt extraction attempts)
Cisco Integrated AI Security and Safety Framework
- AISubtech-1.1.1 Instruction Manipulation (challenge-canary and prompt-pattern findings within the inspected text)
- AISubtech-8.4.1 System LLM Prompt Leakage (challenge-canary and prompt-pattern findings within the inspected text)
- AITech-1.1 Direct Prompt Injection and Jailbreak (Prompt-based Manipulation) (challenge-canary and prompt-pattern findings within the inspected text)
- AITech-1.2 Indirect Prompt Injection (challenge-canary and prompt-pattern findings within the inspected text)
- AITech-8.4 Prompt/Meta Extraction (challenge-canary and prompt-pattern findings within the inspected text)
Google Secure AI Framework 2.0 - Risks
- PIJ: Prompt Injection
- SDD: Sensitive Data Disclosure (canary tokens detect system prompt extraction)
Databricks AI Security Framework 3.0
- Model Serving - Inference requests 9.1: Prompt inject
- Model Serving - Inference requests 9.12: LLM Jailbreak
- Agents - Tools MCP Server 13.16: Prompt Injection