AID-D-005.004: Specialized Agent & Session Logging
Pillars: app
Lifecycle: operation
Scope boundary
Owns privacy-bounded agent and session forensic telemetry beyond ordinary API logs, including approved goals, observable rationale summaries, proposed and executed actions, tool bindings, policy decisions, authenticated principals, and memory interactions. It excludes hidden chain-of-thought and raw scratchpads from ordinary logging.
AID-D-005.001: AID-D-005.004 defines specialized agent-session event semantics; AID-D-005.001 supplies the foundational instrumentation, collection, forwarding, and retention pipeline. Agent forensics needs the shared log pipeline but has a narrower, richer event contract.
AID-M-001.003: AID-D-005.004 records what an agent and its skills did during a session; AID-M-001.003 inventories which skill and instruction artifacts are installed, enabled, and reconciled. Session events require stable inventory keys, but activity evidence does not prove the complete deployed skill population.
AID-H-037.001: AID-D-005.004 intentionally logs observable summaries and action evidence without raw hidden reasoning; AID-H-037.001 governs exceptional confidential reasoning-trace access, storage, and break-glass controls. Ordinary forensic logging must not use the exceptional raw-reasoning path as its default data source.
Implementation guidance
- AID-D-005.004-G001: Log approved goals, observable rationale summaries, actions, results, and authenticated policy-decision records without persisting hidden chain-of-thought.
- AID-D-005.004-G002: Emit OTel GenAI-compatible agent and tool spans in parallel with forensic session logs.
- AID-D-005.004-G003: Log all interactions with external knowledge bases (RAG) in a minimally sensitive form.
- AID-D-005.004-G004: Log secure session initialization to bind identity, integrity, and trust state.
- AID-D-005.004-G005: Log every Human-in-the-Loop (HITL) intervention with operator identity and justification.
AID-D-005.004-G001
Concept and privacy boundary
Forensic telemetry must reconstruct the observable execution path without persisting hidden chain-of-thought, internal scratchpads, or raw model reasoning. Record the approved goal identifier and digest, a short user-visible or policy-approved rationale summary, proposed and executed actions, keyed hashes of arguments and results, independent policy/verifier evidence, and the authenticated principal. Raw reasoning capture is prohibited in ordinary logs; exceptional capture must use the explicit break-glass authorization, encryption, access, retention, and review controls in AID-H-037.001.
Step 1: Emit recursively redacted, evidence-bound events
from __future__ import annotations
# File: agent/forensic_logging.py
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
import hashlib
import hmac
import json
import logging
import os
import re
import time
import uuid
from collections.abc import Mapping
from dataclasses import dataclass
from collections.abc import Callable
from typing import Any
agent_logger = logging.getLogger("agent_forensic")
agent_logger.setLevel(logging.INFO)
SENSITIVE_KEY = re.compile(
r"(authorization|cookie|password|passwd|secret|token|api[_-]?key|private[_-]?key)",
re.IGNORECASE,
)
BEARER_VALUE = re.compile(r"(?i)bearer\s+[a-z0-9._~+/=-]+")
API_KEY_VALUE = re.compile(r"(?i)\b(?:sk|api)[-_][a-z0-9_-]{12,}\b")
AGENT_FORENSIC_POLICY = _aidefend_load_signed_policy(
path_env="D005004_FORENSIC_POLICY_PATH",
signature_env="D005004_FORENSIC_POLICY_SIGNATURE",
key_env="D005004_FORENSIC_POLICY_VERIFY_KEY",
schema_version="aidefend.agent-forensic-logging-policy.v1",
)
MAX_AGENT_STEPS = AGENT_FORENSIC_POLICY.get("max_steps")
MAX_SUMMARY_CHARS = AGENT_FORENSIC_POLICY.get("max_summary_chars")
if any(
isinstance(value, bool) or not isinstance(value, int) or value < 1
for value in (MAX_AGENT_STEPS, MAX_SUMMARY_CHARS)
):
raise RuntimeError("signed agent-forensic logging bounds are invalid")
@dataclass(frozen=True)
class AuthenticatedPrincipal:
# Construct this only from verified mTLS/OIDC middleware, never request JSON.
principal_id: str
authentication_event_id: str
workload_id: str
@dataclass(frozen=True)
class AgentDecision:
observable_rationale_summary: str
tool_name: str
tool_params: dict[str, Any]
@dataclass(frozen=True)
class PolicyEvidence:
allowed: bool
policy_id: str
policy_version: str
verifier_id: str
evidence_ref: str
issued_at: str
@dataclass(frozen=True)
class Planner:
"""Concrete deployment adapter bundle; composition root supplies the callable."""
next_step: Callable[[str, list[dict]], AgentDecision]
@dataclass(frozen=True)
class PolicyVerifier:
"""Authenticated policy adapter with a closed PolicyEvidence return contract."""
evaluate: Callable[..., PolicyEvidence]
@dataclass(frozen=True)
class ToolExecutor:
"""Bound tool adapter; no default, no-op, or fail-open implementation exists."""
execute: Callable[[str, dict[str, Any]], dict[str, Any]]
def redact_text(value: str) -> str:
value = BEARER_VALUE.sub("Bearer <redacted>", value)
return API_KEY_VALUE.sub("<redacted-key>", value)
def redact_nested(value: Any) -> Any:
if isinstance(value, Mapping):
return {
str(key): (
"<redacted>"
if SENSITIVE_KEY.search(str(key))
else redact_nested(child)
)
for key, child in value.items()
}
if isinstance(value, (list, tuple, set)):
return [redact_nested(child) for child in value]
if isinstance(value, str):
return redact_text(value)
if value is None or isinstance(value, (bool, int, float)):
return value
return redact_text(str(value))
def evidence_digest(value: Any) -> str:
key = os.environ["AIDEFEND_EVIDENCE_HMAC_KEY"].encode("utf-8")
if len(key) < 32:
raise RuntimeError("AIDEFEND_EVIDENCE_HMAC_KEY must contain at least 32 bytes")
canonical = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
return hmac.new(key, canonical.encode("utf-8"), hashlib.sha256).hexdigest()
def emit_event(*, session_id: str, event_type: str, principal: AuthenticatedPrincipal, fields: dict) -> None:
record = {
"schema_version": "aidefend.agent_forensic.v2",
"event_id": str(uuid.uuid4()),
"observed_at_unix": time.time(),
"event_type": event_type,
"session_id": session_id,
"principal_id": principal.principal_id,
"authentication_event_id": principal.authentication_event_id,
"workload_id": principal.workload_id,
"fields": redact_nested(fields),
}
agent_logger.info(json.dumps(record, sort_keys=True, separators=(",", ":")))
class ForensicAgentRunner:
def __init__(
self,
planner: Planner,
policy_verifier: PolicyVerifier,
tool_executor: ToolExecutor,
principal: AuthenticatedPrincipal,
max_steps: int = MAX_AGENT_STEPS,
):
self.planner = planner
self.policy_verifier = policy_verifier
self.tool_executor = tool_executor
self.principal = principal
self.max_steps = max_steps
def run(self, *, approved_goal_id: str, approved_goal_text: str) -> str:
session_id = str(uuid.uuid4())
history: list[dict] = []
emit_event(
session_id=session_id,
event_type="agent_goal_accepted",
principal=self.principal,
fields={
"approved_goal_id": approved_goal_id,
"approved_goal_hmac_sha256": evidence_digest(approved_goal_text),
},
)
for step_index in range(self.max_steps):
decision = self.planner.next_step(approved_goal_id, history)
args_digest = evidence_digest(decision.tool_params)
emit_event(
session_id=session_id,
event_type="agent_action_proposed",
principal=self.principal,
fields={
"step": step_index,
"approved_goal_id": approved_goal_id,
"observable_rationale_summary": redact_text(
decision.observable_rationale_summary[:MAX_SUMMARY_CHARS]
),
"tool_name": decision.tool_name,
"tool_args_hmac_sha256": args_digest,
},
)
policy = self.policy_verifier.evaluate(
principal_id=self.principal.principal_id,
approved_goal_id=approved_goal_id,
tool_name=decision.tool_name,
args_digest=args_digest,
)
emit_event(
session_id=session_id,
event_type="agent_policy_verdict",
principal=self.principal,
fields={
"step": step_index,
"allowed": policy.allowed,
"policy_id": policy.policy_id,
"policy_version": policy.policy_version,
"verifier_id": policy.verifier_id,
"verifier_evidence_ref": policy.evidence_ref,
"verifier_issued_at": policy.issued_at,
},
)
if not policy.allowed:
break
tool_started = time.monotonic()
try:
result = self.tool_executor.execute(decision.tool_name, decision.tool_params)
except Exception as exc:
emit_event(
session_id=session_id,
event_type="agent_tool_result",
principal=self.principal,
fields={
"step": step_index,
"tool_name": decision.tool_name,
"tool_args_hmac_sha256": args_digest,
"outcome": "error",
"error_type": exc.__class__.__name__,
"latency_ms": round((time.monotonic() - tool_started) * 1000),
},
)
raise
emit_event(
session_id=session_id,
event_type="agent_tool_result",
principal=self.principal,
fields={
"step": step_index,
"tool_name": decision.tool_name,
"tool_args_hmac_sha256": args_digest,
"tool_result_hmac_sha256": evidence_digest(result),
"outcome": "success",
"latency_ms": round((time.monotonic() - tool_started) * 1000),
"goal_complete": bool(result.get("goal_complete", False)),
},
)
history.append({"step": step_index, "result_digest": evidence_digest(result)})
if result.get("goal_complete") is True:
emit_event(
session_id=session_id,
event_type="agent_goal_complete",
principal=self.principal,
fields={"approved_goal_id": approved_goal_id, "steps": step_index + 1},
)
break
return session_id
Step 2: Verify completeness independently
Ship these events to the append-only store defined by AID-D-005.001. For every executed tool call, a separate verifier should confirm that the same session_id contains an authenticated principal, proposed-action event, policy verdict with a resolvable evidence reference, and result digest. Alert on missing stages, duplicate event IDs, unresolved verifier evidence, or a tool execution with no preceding allow verdict. Retain the signed completeness report for at least the incident-retention period.
AID-D-005.004-G002
Concept and stability warning
Forensic logs preserve evidence; traces preserve execution shape, timing, and cross-service correlation. Emit both. The OpenTelemetry GenAI semantic conventions are currently marked Development, so pin the convention version and test attribute/span-name compatibility on upgrades. Use current invoke_agent {agent name} and execute_tool {tool name} naming, and keep raw prompts, hidden reasoning, arguments, and outputs out of span attributes.
Instrument agent and tool spans
from __future__ import annotations
# File: agent/otel_agent_trace.py
import hashlib
import json
from collections.abc import Callable
from opentelemetry import trace
tracer = trace.get_tracer("aidefend.agent.session")
def stable_hash(value: object) -> str:
payload = json.dumps(value, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def set_attrs(span, attrs: dict[str, object]) -> None:
for key, value in attrs.items():
if value is None:
continue
if isinstance(value, (str, bool, int, float)):
span.set_attribute(key, value)
else:
span.set_attribute(key, str(value))
def run_agent_with_trace(
*,
session_id: str,
agent_id: str,
provider_name: str,
model_id: str,
goal_id: str,
planner_callable: Callable[[], object],
) -> object:
with tracer.start_as_current_span(f"invoke_agent {agent_id}") as span:
set_attrs(
span,
{
"gen_ai.operation.name": "invoke_agent",
"gen_ai.provider.name": provider_name,
"gen_ai.agent.name": agent_id,
"gen_ai.request.model": model_id,
"gen_ai.response.model": model_id,
"aidefend.session_id": session_id,
"aidefend.agent_id": agent_id,
"aidefend.goal_id": goal_id,
},
)
return planner_callable()
def run_tool_with_trace(
*,
session_id: str,
agent_id: str,
tool_name: str,
tool_call_id: str,
tool_params: dict,
policy_id: str,
evidence_ref: str,
tool_callable: Callable[[], dict],
) -> dict:
with tracer.start_as_current_span(f"execute_tool {tool_name}") as span:
set_attrs(
span,
{
"gen_ai.operation.name": "execute_tool",
"gen_ai.tool.name": tool_name,
"gen_ai.tool.call.id": tool_call_id,
"aidefend.session_id": session_id,
"aidefend.agent_id": agent_id,
"aidefend.policy_id": policy_id,
"aidefend.tool.params_sha256": stable_hash(tool_params),
"aidefend.evidence.ref": evidence_ref,
"aidefend.redaction.applied": True,
},
)
try:
result = tool_callable()
except Exception as exc:
span.record_exception(exc)
span.set_attribute("aidefend.tool.outcome", "error")
span.set_attribute("aidefend.error.type", exc.__class__.__name__)
raise
span.set_attribute("aidefend.tool.outcome", "success")
span.set_attribute("aidefend.tool.result_sha256", stable_hash(result))
return resultVerification
Open the trace in your collector or observability platform and verify that one agent session has a parent invoke_agent span, child execute_tool spans, the same aidefend.session_id as the forensic JSON logs, and no raw secrets in attributes.
Action: Add tracing decorators or callbacks around the planner, retriever, memory writer, HITL checkpoint, and tool executor. Keep forensic logs as the source of replay detail and use OTel spans for correlation, timing, and cross-service investigation.
AID-D-005.004-G003
Concept
RAG telemetry should prove which retriever and document set participated without copying the raw query or confidential chunks into logs. Record an authenticated principal, session and trace IDs, a keyed query digest, retriever/index version, keyed document-ID digests, scores, authorization policy, and verifier evidence reference.
Instrument the retriever boundary
from __future__ import annotations
# File: agent/rag_audit.py
import json
import logging
import time
import uuid
from agent.forensic_logging import evidence_digest
rag_logger = logging.getLogger("agent_rag")
class AuditedRAGRetriever:
def __init__(self, vector_db_client, index_id: str, index_version: str):
self.db_client = vector_db_client
self.index_id = index_id
self.index_version = index_version
def retrieve_documents(
self,
*,
session_id: str,
trace_id: str,
authenticated_principal_id: str,
query_text: str,
policy_id: str,
policy_version: str,
verifier_evidence_ref: str,
top_k: int = 3,
):
if not authenticated_principal_id or not verifier_evidence_ref:
raise PermissionError("verified identity and retrieval-policy evidence are required")
documents = self.db_client.search(query_text=query_text, top_k=top_k)
event = {
"schema_version": "aidefend.rag_retrieval.v2",
"event_id": str(uuid.uuid4()),
"observed_at_unix": time.time(),
"event_type": "rag_retrieval",
"session_id": session_id,
"trace_id": trace_id,
"principal_id": authenticated_principal_id,
"query_hmac_sha256": evidence_digest(query_text),
"index_id": self.index_id,
"index_version": self.index_version,
"retrieved_documents": [
{
"doc_id_hmac_sha256": evidence_digest(getattr(doc, "id", None)),
"score": float(getattr(doc, "score", 0.0)),
}
for doc in documents
],
"policy_id": policy_id,
"policy_version": policy_version,
"verifier_evidence_ref": verifier_evidence_ref,
}
rag_logger.info(json.dumps(event, sort_keys=True, separators=(",", ":")))
return documents
Verify the detector and retain evidence
For each retrieval, independently resolve the policy evidence, recompute document-ID digests from the retriever audit export, and compare the logged index version with the deployed index manifest. Alert on missing retrieval events, unknown index versions, or unresolvable evidence. The event is fresh per retrieval and contains no raw query or chunk text.
Action: Instrument the retriever boundary with keyed digests and evidence references. Keep raw queries and documents in their authorized data stores, not the general telemetry pipeline.
AID-D-005.004-G004
Resolve identity and trust roots outside the request
The session initializer consumes an authenticated workload context from mTLS/OIDC middleware, an attestation policy and key registry loaded from the deployment trust bundle, and nonce/JTI stores owned by the attestation verifier. A request may carry the token and launch ID, but it cannot choose its workload identity, verification key, issuer, audience, or expected nonce.
Verify one-time attestation and measured bytes
from __future__ import annotations
# File: agent/session_start_logger.py
import hashlib
import hmac
import os
import stat
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping, Protocol
import jwt
@dataclass(frozen=True)
class AuthenticatedWorkload:
principal_id: str
authentication_event_id: str
@dataclass(frozen=True)
class AttestationPolicy:
policy_version: str
issuer: str
audience: str
maximum_attestation_age_seconds: int
clock_leeway_seconds: int
@dataclass(frozen=True)
class SessionCommitReceipt:
session_id: str
event_id: str
acknowledgement_id: str
class SessionInitializationStore(Protocol):
def commit_once(
self,
*,
launch_id: str,
principal_id: str,
issuer: str,
jti: str,
presented_nonce: str,
expires_at: int,
event: dict,
) -> SessionCommitReceipt: ...
def _hash_regular_file(path: Path) -> str:
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
digest = hashlib.sha256()
try:
info = os.fstat(descriptor)
if not stat.S_ISREG(info.st_mode):
raise ValueError("agent bundle is not a regular file")
while True:
block = os.read(descriptor, 1024 * 1024)
if not block:
break
digest.update(block)
finally:
os.close(descriptor)
return digest.hexdigest()
def initialize_agent_session(
*,
auth: AuthenticatedWorkload,
launch_id: str,
agent_bundle_path: Path,
attestation_token: str,
policy: AttestationPolicy,
trusted_keys_by_kid: Mapping[str, object],
initialization_store: SessionInitializationStore,
) -> str:
if not auth.principal_id or not auth.authentication_event_id or not launch_id:
raise PermissionError("authenticated launch binding is incomplete")
header = jwt.get_unverified_header(attestation_token)
key_id = header.get("kid")
if header.get("alg") != "EdDSA" or not isinstance(key_id, str):
raise PermissionError("attestation header algorithm or key ID is invalid")
verification_key = trusted_keys_by_kid.get(key_id)
if verification_key is None:
raise PermissionError("attestation key is absent from the pinned registry")
code_digest = _hash_regular_file(agent_bundle_path)
claims = jwt.decode(
attestation_token,
verification_key,
algorithms=["EdDSA"],
audience=policy.audience,
issuer=policy.issuer,
options={
"require": [
"iss", "aud", "sub", "iat", "exp", "jti", "nonce",
"code_sha256", "evidence_ref",
]
},
leeway=policy.clock_leeway_seconds,
)
now = int(time.time())
issued_at, expires_at = int(claims["iat"]), int(claims["exp"])
if (
issued_at > now + policy.clock_leeway_seconds
or now - issued_at > policy.maximum_attestation_age_seconds
or expires_at <= now
):
raise PermissionError("attestation is stale, future-dated, or expired")
if not hmac.compare_digest(str(claims["sub"]), auth.principal_id):
raise PermissionError("attestation subject differs from authenticated workload")
presented_nonce = str(claims["nonce"])
if not presented_nonce:
raise PermissionError("attestation nonce is absent")
if not hmac.compare_digest(str(claims["code_sha256"]), code_digest):
raise PermissionError("attested code digest differs from measured bundle")
event = {
"schema_version": "aidefend.agent_session_start.v3",
"event_type": "agent_session_start",
"launch_id": launch_id,
"principal_id": auth.principal_id,
"authentication_event_id": auth.authentication_event_id,
"code_sha256": code_digest,
"attestation_issuer": policy.issuer,
"attestation_key_id": key_id,
"attestation_jti": str(claims["jti"]),
"attestation_evidence_ref": claims["evidence_ref"],
"attestation_issued_at": issued_at,
"attestation_expires_at": expires_at,
"policy_version": policy.policy_version,
"observed_at_unix": now,
}
commit = initialization_store.commit_once(
launch_id=launch_id,
principal_id=auth.principal_id,
issuer=policy.issuer,
jti=str(claims["jti"]),
presented_nonce=presented_nonce,
expires_at=expires_at,
event=event,
)
if not all((commit.session_id, commit.event_id, commit.acknowledgement_id)):
raise RuntimeError("session initialization did not return a durable receipt")
return commit.session_idLaunch and replay tests
Issue the nonce before requesting attestation. After JWT, subject, time, and measured-code verification, commit_once atomically compares and consumes that nonce, claims the issuer/JTI, and appends the session-start event through a transactional outbox. An exact retry returns the existing session and acknowledgement; a changed token or binding is a conflict. Keep the key registry and initialization store outside the agent's identity. Test attacker-generated keys, unknown kid, wrong subject/audience/nonce/code digest, stale/future tokens, reused JTI, symlinked bundle, and sink outage. An auditor reads the event and referenced evidence back and re-verifies the JWT and measured bundle digest against the historical trust registry.
AID-D-005.004-G005
Concept
HITL telemetry must identify the human through verified authentication context rather than a caller-supplied operator_id. Record the checkpoint, action binding, decision, policy version, and signed approval evidence reference. Store only a recursively redacted justification and a keyed digest of the triggering event.
Emit an authenticated HITL event
from __future__ import annotations
# File: agent/hitl_logger.py
import json
import logging
import time
import uuid
from typing import Literal
from fastapi import HTTPException, Request
from agent.forensic_logging import evidence_digest, redact_nested
hitl_logger = logging.getLogger("agent_hitl")
Decision = Literal["APPROVED", "REJECTED"]
def log_hitl_event(
*,
request: Request,
checkpoint_id: str,
action_digest: str,
trigger_event: dict,
decision: Decision,
justification: str,
policy_id: str,
policy_version: str,
approval_evidence_ref: str,
) -> None:
# Verified OIDC/WebAuthn middleware owns these values.
principal_id = getattr(request.state, "principal_id", None)
authentication_event_id = getattr(request.state, "authentication_event_id", None)
if not principal_id or not authentication_event_id:
raise HTTPException(status_code=401, detail="verified operator identity required")
if not approval_evidence_ref.startswith(("s3://", "https://")):
raise ValueError("approval_evidence_ref must reference retained verifier evidence")
record = {
"schema_version": "aidefend.hitl_intervention.v2",
"event_id": str(uuid.uuid4()),
"observed_at_unix": time.time(),
"event_type": "hitl_intervention",
"checkpoint_id": checkpoint_id,
"action_hmac_sha256": action_digest,
"trigger_event_hmac_sha256": evidence_digest(trigger_event),
"operator_principal_id": str(principal_id),
"authentication_event_id": str(authentication_event_id),
"decision": decision,
"justification": redact_nested(justification)[:500],
"policy_id": policy_id,
"policy_version": policy_version,
"approval_evidence_ref": approval_evidence_ref,
}
hitl_logger.info(json.dumps(record, sort_keys=True, separators=(",", ":")))
Verify the detector and retain evidence
For every high-risk execution, independently join the HITL event to the signed approval and authentication event, re-verify the signature and action digest, and alert when any link is missing. The HITL JSON event is fresh per intervention; the signed approval and identity-provider event are the independent evidence artifacts.
Action: Resolve operator identity only from verified authentication middleware and retain the signed approval reference with every intervention. Confirmation-token replay and one-time consumption are monitored by AID-D-015.
Open-source tools
- LangChain
- Microsoft Agent Framework
- CrewAI
- LlamaIndex
- Loguru
- SPIRE
- OpenTelemetry
- Langfuse
- AgentOps
- OpenInference
Source-available / open-weight tools
- Arize Phoenix (Elastic License 2.0; source-available)
- Sentry self-hosted (FSL-1.1-Apache-2.0; source-available)
Commercial / hosted tools
- Arize AX
- Fiddler AI Observability
- Datadog LLM Observability
- New Relic
- LangSmith
- Braintrust
- Lasso AI Security Platform
Threat mappings
MITRE ATLAS
- AML.T0053 AI Agent Tool Invocation
- AML.T0080 AI Agent Context Poisoning
- AML.T0080.000 AI Agent Context Poisoning: Memory
- AML.T0080.001 AI Agent Context Poisoning: Thread
- AML.T0081 Modify AI Agent Configuration
- AML.T0108 AI Agent
MAESTRO
- Agent Goal Manipulation (L7)
- Agent Tool Misuse (L7)
- Repudiation (L7)
- Regulatory Non-Compliance by AI Security Agents (L6)
OWASP LLM Top 10 2026
- LLM02:2026 Sensitive Information Disclosure (privacy-bounded logging excludes hidden chain-of-thought and raw scratchpads from ordinary telemetry)
- LLM03:2026 Excessive Agency (action and verifier telemetry preserves evidence of agent authority misuse)
OWASP ML Top 10 2023
- N/A
OWASP Top 10 for Agentic Applications 2026
- ASI02:2026 Tool Misuse and Exploitation
- ASI06:2026 Memory & Context Poisoning (session logging captures memory manipulation)
- ASI10:2026 Rogue Agents (detailed session logs reveal rogue behavior)
- ASI01:2026 Agent Goal Hijack (goal, plan-summary, policy, and action telemetry detects goal drift)
- ASI03:2026 Identity and Privilege Abuse
NIST Adversarial Machine Learning 2025
- NISTAML.039 Compromising connected resources (tool and execution events preserve connected-resource action evidence)
Cisco Integrated AI Security and Safety Framework
- AISubtech-16.1.1 Logging Sensitive Conversations (privacy controls on emitted agent activity records)
- AISubtech-2.1.1 Execution Approval Bypass (independent approval-to-execution reconciliation)
- AISubtech-4.1.1 Rogue Agent Introduction (agent launch identity, nonce and code attestation)
- AITech-16.1 Eavesdropping (privacy controls on emitted agent activity records)
- AITech-2.1 Excessive Agency (independent approval-to-execution reconciliation)
- AITech-4.1 Agent Injection (agent launch identity, nonce and code attestation)
Google Secure AI Framework 2.0 - Risks
- RA: Rogue Actions (agent session logs enable detection and forensics of rogue actions)
- SDD: Sensitive Data Disclosure (session logs capture data access patterns)
Databricks AI Security Framework 3.0
- Agents - Core 13.8: Repudiation & Untraceability
- Agents - Core 13.2: Tool Misuse
- Agents - Core 13.13: Rogue Agents in Multi-Agent Systems
- Model Serving - Inference response 10.1: Lack of audit and monitoring inference quality