AID-D-003.007: Sensitive Attribute Inference Detection & Escalation
Sensitive-attribute inference may be accurate and still violate purpose limitation, anti-discrimination, or special-category data handling rules. Coverage includes protected-attribute inference testing before deployment, runtime inference findings, multi-turn context-combination scoring, and cross-tool attribute-derivation findings.
Pillars: data, app
Lifecycle: operation
Scope boundary
Owns findings for protected sensitive attributes inferred from combinations of non-sensitive or semi-sensitive signals, including multi-turn and cross-tool derivation. It does not suppress, redact, block, define use-case policy, or perform pre-release fairness evaluation.
AID-D-003.002: AID-D-003.007 detects inferential derivation of protected attributes; AID-D-003.002 detects direct exposure of PII, secrets, private records, or memorized data. An inferred conclusion may contain no leaked sensitive token and therefore needs a distinct control path.
AID-D-003.005: AID-D-003.007 owns the category-specific sensitive-inference finding; AID-D-003.005 owns the broader multi-turn context, intent-drift, and invariant-breach ledger. Session risk supplies accumulated evidence but does not by itself classify the output as a protected-attribute inference.
AID-M-007.003: AID-D-003.007 detects sensitive-attribute inference during operation; AID-M-007.003 evaluates pre-release protected-group outcome fairness and disparity over a governed population. Inference-time privacy findings and population-level fairness metrics address different harms and evidence sets.
Warning
Regulatory & Ethical Critical
Accurate sensitive-attribute inference can still violate purpose limitation, anti-discrimination, or special-category data rules.
- Multi-turn context can enable a protected inference even when each individual turn appears harmless.
- Calendar, purchase, location, email, and health-adjacent tool results can become highly sensitive when combined, even if each source is individually low sensitivity.
Implementation guidance
- AID-D-003.007-G001: Deploy a protected-attribute inference detector in the output guardrail pipeline and emit structured findings when generated text appears to infer protected attributes from non-sensitive signals.
- AID-D-003.007-G002: Run proactive protected-attribute inference tests before deployment and on every model or prompt update, using structured multi-turn scenarios that try to elicit sensitive profiling from non-sensitive inputs.
- AID-D-003.007-G003: Maintain a session-level context-combination risk score and emit threshold-crossing findings when accumulated signals make sensitive inference feasible.
- AID-D-003.007-G004: For tool-enabled systems, evaluate high-risk cross-tool context combinations and emit category-level findings before merged tool context is handed to downstream policy consumers.
AID-D-003.007-G001
Concept:
This is a detect-side classifier that evaluates generated text for inferred protected attributes, such as health, pregnancy, religion, ethnicity, or political affiliation. It does not suppress, rewrite, or block the answer itself. Its output is a structured finding that downstream response or hardening controls can use to decide whether to redact, escalate, or require human review.
Step 1: Load a real detector with category-specific thresholds
from __future__ import annotations
# File: detect/protected_attribute_detector.py
import hashlib as _aidefend_policy_hashlib
import hmac as _aidefend_policy_hmac
import json as _aidefend_policy_json
import math as _aidefend_policy_math
import os as _aidefend_policy_os
import re as _aidefend_policy_re
import subprocess as _aidefend_policy_subprocess
import tempfile as _aidefend_policy_tempfile
from pathlib import Path as _AidefendPolicyPath
def _aidefend_write_private(path, payload):
descriptor = _aidefend_policy_os.open(
path,
_aidefend_policy_os.O_WRONLY
| _aidefend_policy_os.O_CREAT
| _aidefend_policy_os.O_EXCL,
0o600,
)
with _aidefend_policy_os.fdopen(descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
_aidefend_policy_os.fsync(stream.fileno())
_aidefend_policy_os.chmod(path, 0o400)
def _aidefend_strict_json(raw):
def reject_duplicate_keys(pairs):
value = {}
for key, item in pairs:
if key in value:
raise ValueError("duplicate JSON key: " + key)
value[key] = item
return value
return _aidefend_policy_json.loads(
raw.decode("utf-8", errors="strict"),
object_pairs_hook=reject_duplicate_keys,
parse_constant=lambda value: (_ for _ in ()).throw(
ValueError("non-finite JSON constant: " + value)
),
)
def _aidefend_load_signed_policy(*, path_env, signature_env, key_env, schema_version):
policy_path = _AidefendPolicyPath(_aidefend_policy_os.environ[path_env])
signature_path = _AidefendPolicyPath(_aidefend_policy_os.environ[signature_env])
if policy_path == signature_path:
raise RuntimeError("policy and signature paths must differ")
policy_raw = policy_path.read_bytes()
signature_raw = signature_path.read_bytes()
key_reference = _aidefend_policy_os.environ[key_env]
remote_key = bool(_aidefend_policy_re.match(
r"^[A-Za-z][A-Za-z0-9+.-]*:(?://)?", key_reference
)) and not key_reference.startswith("-----BEGIN ")
key_raw = None if remote_key else (
key_reference.encode("utf-8")
if key_reference.startswith("-----BEGIN ")
else _AidefendPolicyPath(key_reference).read_bytes()
)
verify_timeout = float(_aidefend_policy_os.environ["AIDEFEND_POLICY_VERIFY_TIMEOUT_SECONDS"])
if not _aidefend_policy_math.isfinite(verify_timeout) or verify_timeout <= 0:
raise RuntimeError("policy-verification timeout from the versioned runtime profile is invalid")
with _aidefend_policy_tempfile.TemporaryDirectory(
prefix="aidefend-signed-policy-"
) as directory:
root = _AidefendPolicyPath(directory)
_aidefend_policy_os.chmod(root, 0o700)
policy_snapshot = root / "policy.json"
signature_snapshot = root / "policy.sig"
_aidefend_write_private(policy_snapshot, policy_raw)
_aidefend_write_private(signature_snapshot, signature_raw)
captured = [(policy_snapshot, policy_raw), (signature_snapshot, signature_raw)]
key_argument = key_reference
if key_raw is not None:
key_snapshot = root / "verify-key.pub"
_aidefend_write_private(key_snapshot, key_raw)
captured.append((key_snapshot, key_raw))
key_argument = str(key_snapshot)
try:
_aidefend_policy_subprocess.run(
[
"cosign", "verify-blob", "--key", key_argument,
"--bundle", str(signature_snapshot), str(policy_snapshot),
],
check=True, capture_output=True, text=True, timeout=verify_timeout,
)
if any(
not _aidefend_policy_hmac.compare_digest(path.read_bytes(), expected)
for path, expected in captured
):
raise RuntimeError("private signature-verification snapshot changed")
policy = _aidefend_strict_json(policy_raw)
finally:
for path, _ in captured:
_aidefend_policy_os.chmod(path, 0o600)
if (
not isinstance(policy, dict)
or policy.get("schema_version") != schema_version
or not isinstance(policy.get("policy_version"), str)
or not policy["policy_version"]
):
raise RuntimeError("signature-verified policy schema or version is invalid")
return policy, _aidefend_policy_hashlib.sha256(policy_raw).hexdigest()
from transformers import pipeline
PROTECTED_ATTRIBUTE_POLICY, POLICY_SHA256 = _aidefend_load_signed_policy(
path_env="D003007_POLICY_PATH",
signature_env="D003007_POLICY_SIGNATURE",
key_env="D003007_POLICY_VERIFY_KEY",
schema_version="aidefend.protected-attribute-detection-policy.v1",
)
MODEL_ID = PROTECTED_ATTRIBUTE_POLICY.get("model_id")
MODEL_REVISION = PROTECTED_ATTRIBUTE_POLICY.get("model_revision")
POLICY_VERSION = PROTECTED_ATTRIBUTE_POLICY["policy_version"]
MAX_TOKEN_LENGTH = PROTECTED_ATTRIBUTE_POLICY.get("max_token_length")
CATEGORY_PROMPTS = PROTECTED_ATTRIBUTE_POLICY.get("category_prompts")
THRESHOLDS = PROTECTED_ATTRIBUTE_POLICY.get("category_thresholds")
if (
not isinstance(MODEL_ID, str) or not MODEL_ID
or not isinstance(MODEL_REVISION, str)
or _aidefend_policy_re.fullmatch(r"[0-9a-f]{40}", MODEL_REVISION) is None
or isinstance(MAX_TOKEN_LENGTH, bool) or not isinstance(MAX_TOKEN_LENGTH, int)
or MAX_TOKEN_LENGTH < 1
or not isinstance(CATEGORY_PROMPTS, dict) or not CATEGORY_PROMPTS
or not isinstance(THRESHOLDS, dict) or set(THRESHOLDS) != set(CATEGORY_PROMPTS)
or any(not isinstance(key, str) or not key for key in CATEGORY_PROMPTS)
or any(not isinstance(value, str) or not value for value in CATEGORY_PROMPTS.values())
or any(isinstance(value, bool) or not isinstance(value, (int, float))
or not 0 <= float(value) <= 1 for value in THRESHOLDS.values())
):
raise RuntimeError("signed protected-attribute detector policy is invalid")
classifier = pipeline(
task="zero-shot-classification",
model=MODEL_ID,
revision=MODEL_REVISION,
trust_remote_code=False,
truncation=True,
max_length=MAX_TOKEN_LENGTH,
)
def score_output(answer_text: str) -> list[dict[str, float | str]]:
result = classifier(
answer_text,
candidate_labels=list(CATEGORY_PROMPTS.values()),
multi_label=True,
)
labels, scores = result["labels"], result["scores"]
if (len(labels) != len(scores) or len(labels) != len(CATEGORY_PROMPTS)
or len(set(labels)) != len(labels)
or set(labels) != set(CATEGORY_PROMPTS.values())
or any(isinstance(v, bool) or not isinstance(v, (int, float))
or not _aidefend_policy_math.isfinite(v) or not 0 <= v <= 1 for v in scores)):
raise ValueError("incomplete or invalid detector category scores")
scores_by_prompt = {
label: float(score)
for label, score in zip(result["labels"], result["scores"])
}
hits = []
for category, prompt in CATEGORY_PROMPTS.items():
score = scores_by_prompt.get(prompt, 0.0)
if score >= THRESHOLDS[category]:
hits.append({"label": category, "score": round(score, 4)})
return hitsStep 2: Emit a finding record instead of mutating the model output
from __future__ import annotations
# File: detect/protected_attribute_findings.py
import hashlib
from datetime import datetime, timezone
from protected_attribute_detector import (
MODEL_ID, MODEL_REVISION, POLICY_VERSION, POLICY_SHA256, score_output,
)
def evaluate_answer(request_id: str, session_id: str, answer_text: str) -> dict[str, object]:
if not all(isinstance(v, str) and v for v in (request_id, session_id)) or not isinstance(answer_text, str):
raise ValueError("server-bound request, session and exact answer text required")
binding = {"policy_version": POLICY_VERSION, "policy_sha256": POLICY_SHA256,
"detector_model": MODEL_ID, "detector_revision": MODEL_REVISION,
"answer_sha256": hashlib.sha256(answer_text.encode("utf-8")).hexdigest()}
try:
hits = score_output(answer_text)
except Exception as error:
return {**binding, "schema_version": "aidefend.protected-inference-finding.v2",
"event_type": "protected_attribute_inference_finding",
"request_id": request_id, "session_id": session_id,
"ts": datetime.now(timezone.utc).isoformat(),
"coverage": "ERROR", "finding_status": "UNKNOWN",
"detected": None, "risk_score": None, "categories": [],
"recommended_action": "detector_error_review", "reason_code": type(error).__name__}
max_score = max((hit["score"] for hit in hits), default=0.0)
return {
**binding,
"schema_version": "aidefend.protected-inference-finding.v2",
"coverage": "PASS",
"finding_status": "FINDING" if hits else "NO_FINDING",
"event_type": "protected_attribute_inference_finding",
"ts": datetime.now(timezone.utc).isoformat(),
"request_id": request_id,
"session_id": session_id,
"detected": bool(hits),
"risk_score": round(max_score, 4),
"categories": sorted(hit["label"] for hit in hits),
"recommended_action": "review_or_redact" if hits else "none",
}Action: Run this detector after candidate generation and before user delivery, bind the finding to the exact answer digest, request/session, signature-verified policy bytes, and immutable detector revision, persist it, and hand the result to a separate output policy or HITL control. Keeping the detector and the enforcement path separate gives AIDEFEND clean evidence boundaries for “can detect inferred sensitive disclosures” versus “can actively suppress them.”
Verify safely
An independent verifier must replay signed explicit-inference, implicit-inference, benign, quotation, negation, multilingual, ambiguous, unsupported, and detector-error fixtures and compare category scores, evidence spans, and findings, read evidence back, verify signatures and digests, and compare the canonical result digest.
AID-D-003.007-G002
Load a signed, non-empty scenario manifest
The suite loader verifies the manifest signature and returns its canonical bytes and parsed cases. The runner recomputes the digest, rejects an empty or duplicate population, preserves each typed multi-turn role, and distinguishes a security regression from a model or detector execution error.
Run the complete suite through the real path
from __future__ import annotations
# File: evals/protected_inference_suite.py
import hashlib
from collections.abc import Callable
from dataclasses import dataclass
InvokeModel = Callable[[list[dict]], dict]
EvaluateOutput = Callable[[str, str], dict]
ALLOWED_ROLES = {"system", "developer", "user", "assistant", "tool"}
EXPECTED_FINDINGS = {"FINDING", "NO_FINDING"}
@dataclass(frozen=True)
class VerifiedSuite:
canonical_bytes: bytes
cases: tuple[dict, ...]
def run_suite(
suite: VerifiedSuite,
*,
invoke_model: InvokeModel,
evaluate_output: EvaluateOutput,
model_revision: str,
detector_revision: str,
expected_suite_sha256: str,
) -> dict:
observed_suite_sha256 = hashlib.sha256(suite.canonical_bytes).hexdigest()
if observed_suite_sha256 != expected_suite_sha256:
raise ValueError("suite bytes differ from the signed deployment manifest")
cases = list(suite.cases)
if not cases:
raise ValueError("protected-inference suite is empty")
scenario_ids = [case.get("scenario_id") for case in cases]
if any(not isinstance(value, str) or not value for value in scenario_ids):
raise ValueError("scenario ID is absent")
if len(scenario_ids) != len(set(scenario_ids)):
raise ValueError("scenario ID is duplicated")
results = []
execution_errors = []
for case in cases:
required = {"scenario_id", "target", "turns", "expected_finding_status"}
if set(case) != required or case["expected_finding_status"] not in EXPECTED_FINDINGS:
raise ValueError("scenario schema or expected result is invalid")
if not isinstance(case["target"], str) or not case["target"].strip():
raise ValueError("scenario target is absent")
if not isinstance(case["turns"], list) or not case["turns"]:
raise ValueError("scenario turn population is empty")
messages = []
for turn in case["turns"]:
if (
not isinstance(turn, dict)
or set(turn) != {"role", "content"}
or turn["role"] not in ALLOWED_ROLES
or not isinstance(turn["content"], str)
or not turn["content"]
):
raise ValueError("typed multi-turn message is invalid")
messages.append({"role": turn["role"], "content": turn["content"]})
try:
response = invoke_model(messages)
if set(response) != {"response_id", "text", "model_revision"}:
raise ValueError("model adapter response schema mismatch")
if response["model_revision"] != model_revision:
raise ValueError("model revision drifted during the suite")
response_sha256 = hashlib.sha256(response["text"].encode("utf-8")).hexdigest()
detector = evaluate_output(response["response_id"], response["text"])
evaluated_targets = detector.get("evaluated_targets")
finding_targets = detector.get("finding_targets")
if (
detector.get("detector_revision") != detector_revision
or detector.get("response_sha256") != response_sha256
or detector.get("control_status") != "PASS"
or detector.get("finding_status") not in EXPECTED_FINDINGS
or not isinstance(evaluated_targets, list)
or len(evaluated_targets) != len(set(evaluated_targets))
or any(not isinstance(item, str) or not item for item in evaluated_targets)
or not isinstance(finding_targets, list)
or len(finding_targets) != len(set(finding_targets))
or any(item not in evaluated_targets for item in finding_targets)
or case["target"] not in evaluated_targets
or detector["finding_status"] != ("FINDING" if finding_targets else "NO_FINDING")
):
raise ValueError("detector receipt is erroneous, incomplete, or bound to another response")
observed_target_status = (
"FINDING" if case["target"] in finding_targets else "NO_FINDING"
)
passed = observed_target_status == case["expected_finding_status"]
results.append({
"scenario_id": case["scenario_id"],
"target": case["target"],
"response_id": response["response_id"],
"response_sha256": response_sha256,
"observed_finding_status": observed_target_status,
"all_finding_targets": finding_targets,
"expected_finding_status": case["expected_finding_status"],
"passed": passed,
})
except Exception as error:
execution_errors.append({
"scenario_id": case["scenario_id"],
"error_class": type(error).__name__,
})
if execution_errors:
control_status, finding_status = "ERROR", "UNKNOWN"
else:
failed = [item["scenario_id"] for item in results if not item["passed"]]
control_status = "PASS"
finding_status = "FINDING" if failed else "NO_FINDING"
return {
"schema_version": "aidefend.protected_inference_suite.v2",
"control_status": control_status,
"finding_status": finding_status,
"suite_sha256": observed_suite_sha256,
"model_revision": model_revision,
"detector_revision": detector_revision,
"expected_scenario_count": len(cases),
"evaluated_scenario_count": len(results),
"execution_errors": execution_errors,
"results": results,
}Verify and retain the result
Canonicalize and sign the scenario manifest before the verified loader constructs VerifiedSuite. Replay safe, direct, indirect, hedged, quoted, negated, multilingual, multi-turn, timeout, abstention, malformed-result, wrong-role, empty-suite, revision-mismatch, target-mismatch, and unrelated-finding fixtures. A separate runner re-verifies the manifest and reconciles every scenario ID and result. Retain response digests rather than raw sensitive outputs in the general report.
AID-D-003.007-G003
Bind scoring to a signed expected-turn manifest
Keep raw sensitive text inside the authorized category detector. Before scoring a closed window, load a signature-verified manifest from the authoritative conversation ledger. It binds tenant, session, window, exact expected turn IDs, count, and population root. Every pinned category-detector receipt binds the same identity, turn ID, event time, detector revision, content digest, and category hits. Missing turns therefore remain visible instead of disappearing from a process-local counter.
from __future__ import annotations
# File: detect/context_combination_risk.py
import hashlib
import json
import math
import re
from dataclasses import dataclass
from typing import Mapping, Sequence
HEX64 = re.compile(r"^[0-9a-f]{64}$")
@dataclass(frozen=True)
class VerifiedTurnManifest:
tenant_id: str
session_id: str
window_start_unix: float
window_end_unix: float
expected_turn_ids: tuple[str, ...]
expected_turn_count: int
expected_turn_root_sha256: str
manifest_sha256: str
@dataclass(frozen=True)
class VerifiedCategoryReceipt:
tenant_id: str
session_id: str
turn_id: str
observed_at_unix: float
detector_revision: str
content_hmac_sha256: str
categories: tuple[str, ...]
receipt_sha256: str
@dataclass(frozen=True)
class ContextRiskPolicy:
policy_version: str
detector_revision: str
category_weights: Mapping[str, float]
combination_weights: Mapping[tuple[str, ...], float]
decay_half_life_seconds: float
combination_activity_floor: float
finding_threshold: float
def population_root(turn_ids: Sequence[str]) -> str:
raw = json.dumps(sorted(turn_ids), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
def evaluate_session(*, tenant_id: str, session_id: str,
manifest: VerifiedTurnManifest,
receipts: Sequence[VerifiedCategoryReceipt],
policy: ContextRiskPolicy, as_of_unix: float) -> dict:
expected_ids = tuple(manifest.expected_turn_ids)
if (
not tenant_id or not session_id or not policy.policy_version
or manifest.tenant_id != tenant_id or manifest.session_id != session_id
or not math.isfinite(manifest.window_start_unix)
or not math.isfinite(manifest.window_end_unix)
or manifest.window_start_unix >= manifest.window_end_unix
or manifest.window_end_unix > as_of_unix
or isinstance(manifest.expected_turn_count, bool)
or manifest.expected_turn_count < 1
or manifest.expected_turn_count != len(expected_ids)
or len(expected_ids) != len(set(expected_ids))
or any(not isinstance(item, str) or not item for item in expected_ids)
or HEX64.fullmatch(manifest.expected_turn_root_sha256) is None
or HEX64.fullmatch(manifest.manifest_sha256) is None
or population_root(expected_ids) != manifest.expected_turn_root_sha256
):
raise ValueError("verified expected-turn manifest is malformed or misbound")
half_life = float(policy.decay_half_life_seconds)
activity_floor = float(policy.combination_activity_floor)
threshold = float(policy.finding_threshold)
weights = {str(name): float(value) for name, value in policy.category_weights.items()}
if (
not math.isfinite(as_of_unix) or not math.isfinite(half_life)
or not math.isfinite(activity_floor) or not math.isfinite(threshold)
or half_life <= 0 or activity_floor <= 0 or threshold < 0
or not weights or any(not name or not math.isfinite(value) or value < 0
for name, value in weights.items())
):
raise ValueError("signed scoring policy is invalid")
seen_turns = set()
ordered = sorted(receipts, key=lambda item: (item.observed_at_unix, item.turn_id))
for receipt in ordered:
if (
receipt.tenant_id != tenant_id or receipt.session_id != session_id
or receipt.detector_revision != policy.detector_revision
or not receipt.turn_id or receipt.turn_id in seen_turns
or HEX64.fullmatch(receipt.content_hmac_sha256) is None
or HEX64.fullmatch(receipt.receipt_sha256) is None
or not math.isfinite(receipt.observed_at_unix)
or not manifest.window_start_unix <= receipt.observed_at_unix < manifest.window_end_unix
or len(receipt.categories) != len(set(receipt.categories))
or set(receipt.categories) - set(weights)
):
raise ValueError("category receipt is duplicated, malformed, or misbound")
seen_turns.add(receipt.turn_id)
expected_set = set(expected_ids)
missing = sorted(expected_set - seen_turns)
unexpected = sorted(seen_turns - expected_set)
observed_root = population_root(tuple(seen_turns))
population_evidence = {
"manifest_sha256": manifest.manifest_sha256,
"expected_turn_count": manifest.expected_turn_count,
"observed_turn_count": len(seen_turns),
"expected_turn_root_sha256": manifest.expected_turn_root_sha256,
"observed_turn_root_sha256": observed_root,
}
if missing or unexpected:
return {
"schema_version": "aidefend.context_combination_finding.v3",
"control_status": "INSUFFICIENT_DATA", "finding_status": "UNKNOWN",
"tenant_id": tenant_id, "session_id": session_id,
"policy_version": policy.policy_version, **population_evidence,
"missing_turn_ids": missing, "unexpected_turn_ids": unexpected,
"reason": "expected and observed turn populations do not reconcile",
}
scores = {name: 0.0 for name in weights}
for receipt in ordered:
decay = 0.5 ** ((as_of_unix - receipt.observed_at_unix) / half_life)
for category in receipt.categories:
scores[category] += weights[category] * decay
active = {name for name, score in scores.items() if score >= activity_floor}
hits, combination_score, seen_combinations = [], 0.0, set()
for members, raw_weight in policy.combination_weights.items():
member_tuple, weight = tuple(sorted(members)), float(raw_weight)
if (
not member_tuple or len(member_tuple) != len(set(member_tuple))
or set(member_tuple) - set(weights) or not math.isfinite(weight)
or weight < 0 or member_tuple in seen_combinations
):
raise ValueError("signed category combination is invalid or duplicated")
seen_combinations.add(member_tuple)
if set(member_tuple).issubset(active):
hits.append(member_tuple)
combination_score += weight
risk_score = sum(scores.values()) + combination_score
return {
"schema_version": "aidefend.context_combination_finding.v3",
"control_status": "PASS",
"finding_status": "FINDING" if risk_score >= threshold else "NO_FINDING",
"tenant_id": tenant_id, "session_id": session_id,
"policy_version": policy.policy_version,
"detector_revision": policy.detector_revision,
**population_evidence, "population_reconciled": True,
"as_of_unix": as_of_unix,
"category_scores": {key: round(value, 6) for key, value in scores.items()},
"combination_hits": hits, "risk_score": round(risk_score, 6),
"threshold": threshold,
}
Verification and privacy boundary
The manifest verifier, category-receipt verifier, and signed-policy loader are startup dependencies, not request-provided callbacks. Convert malformed signed inputs or verifier failures to ERROR at the runner boundary. Rebuild each result from the append-only conversation ledger, independently regenerate the expected-turn manifest, and compare exact IDs, count, population root, session binding, detector revision, decay time, and policy version. Replay one missing middle turn, several missing turns, an unexpected turn, duplicate, late, cross-tenant, unsupported-category, restart, threshold-boundary, multilingual, and detector-error fixtures. No partial receipt population may return PASS or NO_FINDING. Downstream controls may consume the finding, but this Detect guidance does not prune context or block a user.
AID-D-003.007-G004
Concept:
Tool outputs may be individually low sensitivity but highly sensitive in combination. This step tags tool results, evaluates risky category combinations, and emits a finding with the relevant risk code. It does not block the tool result set by itself; a separate policy or HITL component should decide whether to suppress, compartmentalize, or redact the combined context.
Step 1: Normalize tool-output categories and evaluate the combination
# File: detect/tool_combination_risk.py
HIGH_RISK_MATRIX = {
frozenset(['calendar', 'health']): 'religion_or_health_inference',
frozenset(['purchases', 'location']): 'political_or_financial_inference',
frozenset(['email_summary', 'calendar', 'location']): 'religion_relationship_or_health_inference',
}
def evaluate_tool_combination(result_category_sets: list[set[str]]) -> list[str]:
merged = set()
for categories in result_category_sets:
merged.update(categories)
hits = []
for required_categories, risk_name in HIGH_RISK_MATRIX.items():
if required_categories.issubset(merged):
hits.append(risk_name)
return sorted(hits)Step 2: Emit a finding instead of a deny decision
from __future__ import annotations
# File: detect/tool_combination_findings.py
from datetime import datetime, timezone
from tool_combination_risk import evaluate_tool_combination
def detect_cross_tool_inference(
request_id: str,
session_id: str,
tool_results: list[dict[str, object]],
) -> dict[str, object]:
category_sets = [set(result['categories']) for result in tool_results]
hits = evaluate_tool_combination(category_sets)
merged_categories = sorted({category for categories in category_sets for category in categories})
return {
'event_type': 'cross_tool_inference_finding',
'ts': datetime.now(timezone.utc).isoformat(),
'request_id': request_id,
'session_id': session_id,
'detected': bool(hits),
'risk_codes': hits,
'merged_categories': merged_categories,
'recommended_action': 'review_or_compartmentalize' if hits else 'none',
}Action: Run this detector before tool results are merged into model context, log only category-level evidence rather than raw sensitive content, and pass the finding to a separate policy consumer. This preserves a clean evidence schema for “can detect risky cross-tool inference conditions” without conflating it with downstream blocking or redaction logic.
Verify complete coverage
The population is every policy-declared high-risk tool-result combination actually assembled for each applicable request or session, plus all eligible combinations omitted, failed, stale, or rejected by the assembler.
Verify safely
An independent verifier must reconstruct each assembled context from immutable tool-result digests, independently recompute category combinations, and replay approved, high-risk, stale, cross-session, missing-result, and cross-tenant fixtures, read evidence back, verify signatures and digests, and compare the canonical result digest.
Open-source tools
- Fairlearn (group-disparity metrics for governed evaluation populations)
- AI Fairness 360 (pre-deployment bias and group-disparity analysis)
- Aequitas (group bias audit reporting)
- Guardrails AI (custom output validators for prohibited sensitive-attribute claims)
- Microsoft Presidio (explicit attribute and custom-pattern detection; not semantic inference by itself)
- Hugging Face Transformers (fine-tuned classification of sensitive-attribute inference statements)
- spaCy (rule-based phrase and entity features for deterministic screening)
Commercial / hosted tools
- Arthur AI Platform (group bias monitoring for governed populations)
- Holistic AI Platform (pre-deployment bias and protected-characteristic testing)
- Fiddler AI Observability (custom fairness metrics and alerts for governed populations)
- Patronus AI (custom output evaluators for sensitive-attribute inference claims)
Threat mappings
MITRE ATLAS
- AML.T0024 Exfiltration via AI Inference API (sensitive attribute disclosure via model output)
- AML.T0024.001 Exfiltration via AI Inference API: Invert AI Model (model responses can be probed to derive sensitive attributes)
- AML.T0057 LLM Data Leakage (inference-derived sensitive disclosures through generated output)
- AML.T0048.003 External Harms: User Harm (harm from unwanted or incorrect sensitive profiling)
- AML.T0085 Data from AI Services (tool-mediated data collection combined to derive sensitive attributes)
MAESTRO
- Data Exfiltration (L2) (inferential exposure of protected attributes)
- Data Leakage through Observability (L5) (sensitive inferences appearing in logs, traces, or dashboards)
OWASP LLM Top 10 2026
- LLM02:2026 Sensitive Information Disclosure
OWASP ML Top 10 2023
- ML03:2023 Model Inversion Attack (inversion can expose protected attributes)
OWASP Top 10 for Agentic Applications 2026
- ASI02:2026 Tool Misuse and Exploitation (tool outputs combined to infer sensitive attributes)
NIST Adversarial Machine Learning 2025
- NISTAML.034 Property Inference (protected-attribute inference is the exact observed behavior)
- NISTAML.032 Reconstruction (reconstructive outputs can expose protected attributes)
- NISTAML.036 Leaking information from user interactions (accumulated interaction context can enable protected-attribute inference)
Cisco Integrated AI Security and Safety Framework
- AISubtech-8.3.3 Personally Identifiable Information Exposure
- AISubtech-8.3.5 Protected Health Information Exposure
- AITech-8.2 Data Exfiltration / Exposure
- AITech-8.3 Information Disclosure
Google Secure AI Framework 2.0 - Risks
- ISD: Inferred Sensitive Data
- SDD: Sensitive Data Disclosure (inference-derived sensitive disclosures)
- EDH: Excessive Data Handling (cross-tool data combination exceeds intended data handling scope)
Databricks AI Security Framework 3.0
- Model Serving - Inference response 10.6: Sensitive data output from a model