diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2fbc10..c4ad43f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- **Verified operator identity ([#55](https://github.com/ARPAHLS/aura/issues/55))** — optional identity adapters (manual, mock, OIDC, Auth0); `identity.bound` spine event; `ids.operator` on all event trailers; export redaction; `aura identity show`; profile `types` with `role: identity`.
+
- **Session lifecycle invariants ([#15](https://github.com/ARPAHLS/aura/issues/15))** — strict closed-session errors (`SessionClosedError`, `SessionAlreadyOpenError`); `export=False` builds in-memory `summary` and `audit_report` on `SessionRun`; atomic summary + OTel commit on export; frozen `declared_rules` / `open_snapshot_hash` at open (runtime rule merges via skill bind still apply to constraints); `trace_id` on summary export.
- **Documentation sweep ([#14](https://github.com/ARPAHLS/aura/issues/14))** — `docs/INDEX.md` three-tier entry (Start / Build / Decide + Optional vision); demoted narrative, three-rings, aura-levels, field-services; refreshed architecture, concepts, stack-position, field-services shipped vs planned; fixed stale v0.2 voice in getting-started and concepts.
diff --git a/README.md b/README.md
index 4ca0950..f122894 100644
--- a/README.md
+++ b/README.md
@@ -112,7 +112,7 @@ with ag.session() as run:
print(run.exports)
```
-CLI: `aura agent create`, `aura run`, `aura export`, `aura report show`, `aura compare`, `aura export-otel`, `aura verify chain`.
+CLI: `aura agent create`, `aura run`, `aura export`, `aura report show`, `aura identity show`, `aura compare`, `aura export-otel`, `aura verify chain`.
→ [getting-started.md](docs/getting-started.md) · [onboarding.md](docs/onboarding.md) · [examples/](examples/)
@@ -125,7 +125,7 @@ CLI: `aura agent create`, `aura run`, `aura export`, `aura report show`, `aura c
| **Index** | [docs/INDEX.md](docs/INDEX.md) — full doc map (Start / Build / Decide) |
| **Start** | [onboarding.md](docs/onboarding.md) · [getting-started.md](docs/getting-started.md) · [concepts.md](docs/concepts.md) · [using-aura.md](docs/using-aura.md) |
| **Integration** | [reference-tool-host-capstone.md](docs/guides/reference-tool-host-capstone.md) · [guides/aura-on-skillware.md](docs/guides/aura-on-skillware.md) · [skillware-integration.md](docs/skillware-integration.md) · [sequencer.md](docs/sequencer.md) |
-| **Identity & audit** | [trust-paths.md](docs/trust-paths.md) · [outputs.md](docs/outputs.md) |
+| **Identity & audit** | [trust-paths.md](docs/trust-paths.md) · [integrations/identity/](integrations/identity/) · [outputs.md](docs/outputs.md) |
| **Compare & position** | [comparison.md](docs/comparison.md) · [ROADMAP.md](docs/ROADMAP.md) |
| **Contribute** | [CONTRIBUTING.md](CONTRIBUTING.md) · [Agent workflow](docs/contributing/ai_native_workflow.md) · [TESTING.md](docs/TESTING.md) · [PUBLISHING.md](docs/PUBLISHING.md) · [CHANGELOG.md](CHANGELOG.md) |
diff --git a/aura/__init__.py b/aura/__init__.py
index 75bf306..2108445 100644
--- a/aura/__init__.py
+++ b/aura/__init__.py
@@ -4,6 +4,10 @@
AgentHandle,
ApprovalRequired,
ExportError,
+ IdentityOptions,
+ IdentityRequiredError,
+ IdentityVerificationError,
+ OperatorIdentityAdapter,
SessionClosedError,
SessionNotOpenError,
SessionRun,
@@ -29,4 +33,8 @@
"SessionClosedError",
"SessionNotOpenError",
"ExportError",
+ "IdentityOptions",
+ "OperatorIdentityAdapter",
+ "IdentityRequiredError",
+ "IdentityVerificationError",
]
diff --git a/aura/agents/profile.py b/aura/agents/profile.py
index 809dde1..3e03963 100644
--- a/aura/agents/profile.py
+++ b/aura/agents/profile.py
@@ -21,6 +21,7 @@ class AgentProfile:
skills: list[str] = field(default_factory=list)
sequencer: dict[str, Any] | None = None
observers: list[dict[str, Any]] = field(default_factory=list)
+ types: list[dict[str, Any]] = field(default_factory=list)
default_mode: str = "script"
archived: bool = False
@@ -37,6 +38,7 @@ def to_dict(self) -> dict[str, Any]:
"skills": self.skills,
"sequencer": self.sequencer,
"observers": self.observers,
+ "types": self.types,
"default_mode": self.default_mode,
"archived": self.archived,
}
@@ -55,6 +57,7 @@ def from_dict(cls, data: dict[str, Any]) -> AgentProfile:
skills=list(data.get("skills") or []),
sequencer=data.get("sequencer"),
observers=list(data.get("observers") or []),
+ types=list(data.get("types") or []),
default_mode=data.get("default_mode", "script"),
archived=bool(data.get("archived", False)),
)
diff --git a/aura/agents/registry.py b/aura/agents/registry.py
index d894d0a..3cf6246 100644
--- a/aura/agents/registry.py
+++ b/aura/agents/registry.py
@@ -77,6 +77,7 @@ def create(
skills: list[str] | None = None,
sequencer: dict[str, Any] | None = None,
observers: list[dict[str, Any]] | None = None,
+ types: list[dict[str, Any]] | None = None,
ids: dict[str, Any] | None = None,
default_mode: str = "script",
) -> AgentProfile:
@@ -117,6 +118,7 @@ def create(
skills=skills or [],
sequencer=sequencer,
observers=observers or [],
+ types=types or [],
default_mode=default_mode,
)
self.save(profile)
diff --git a/aura/api.py b/aura/api.py
index 5653af4..bae5a0c 100644
--- a/aura/api.py
+++ b/aura/api.py
@@ -16,6 +16,9 @@
from aura.core.errors import ExportError, SessionClosedError, SessionNotOpenError
from aura.core.session import Session, SessionMode
from aura.exporters.jsonl import build_session_summary, export_session
+from aura.identity.bind import IdentityOptions
+from aura.identity.errors import IdentityRequiredError, IdentityVerificationError
+from aura.identity.protocol import OperatorIdentityAdapter
@dataclass
@@ -102,11 +105,19 @@ def session(
rules: list[dict[str, Any]] | None = None,
export: bool | None = None,
sequencer: dict[str, Any] | None = None,
+ identity_adapter: OperatorIdentityAdapter | None = None,
+ operator: dict[str, Any] | None = None,
+ identity: dict[str, Any] | None = None,
) -> Iterator[SessionRun]:
cfg = get_config()
session = _build_session(self, mode, rules, sequencer)
run = SessionRun(_session=session)
- session.open(cfg.sessions_dir())
+ identity_options = IdentityOptions(
+ adapter=identity_adapter,
+ operator=operator,
+ config=dict(identity or {}),
+ )
+ session.open(cfg.sessions_dir(), identity_options=identity_options)
token = _current_run.set(run)
try:
yield run
@@ -189,4 +200,8 @@ def list_agents(include_archived: bool = False) -> list[AgentProfile]:
"SessionNotOpenError",
"ExportError",
"current_session",
+ "IdentityOptions",
+ "OperatorIdentityAdapter",
+ "IdentityRequiredError",
+ "IdentityVerificationError",
]
diff --git a/aura/cli/commands.py b/aura/cli/commands.py
index 1065778..b9056d9 100644
--- a/aura/cli/commands.py
+++ b/aura/cli/commands.py
@@ -258,6 +258,31 @@ def cmd_export_otel(session_id: str, *, console: Console | None = None) -> int:
return 0
+def cmd_identity_show(*, console: Console | None = None) -> int:
+ from aura.config import get_config
+
+ cfg = get_config()
+ payload = {
+ "identity": cfg.values.get("identity") or {},
+ "identity_required": cfg.values.get("identity_required", False),
+ "identity_export_pii": cfg.values.get("identity_export_pii", False),
+ "identity_redact_fields": cfg.values.get("identity_redact_fields", []),
+ "env_hints": {
+ "AURA_OIDC_TOKEN": bool(__import__("os").environ.get("AURA_OIDC_TOKEN")),
+ "AURA_AUTH0_DOMAIN": bool(__import__("os").environ.get("AURA_AUTH0_DOMAIN")),
+ "AURA_MOCK_OPERATOR_SUBJECT": bool(
+ __import__("os").environ.get("AURA_MOCK_OPERATOR_SUBJECT")
+ ),
+ },
+ }
+ text = json.dumps(payload, indent=2)
+ if console is None:
+ print(text)
+ else:
+ console.print(text, style="dim")
+ return 0
+
+
def cmd_compare(session_a: str, session_b: str, *, console: Console | None = None) -> int:
from aura.config import get_config
diff --git a/aura/cli/help_text.py b/aura/cli/help_text.py
index 8d14a4c..46d869e 100644
--- a/aura/cli/help_text.py
+++ b/aura/cli/help_text.py
@@ -8,6 +8,7 @@
_DOCS_CLI = "https://github.com/ARPAHLS/aura/blob/main/docs/using-aura.md"
_DOCS_GETTING_STARTED = "https://github.com/ARPAHLS/aura/blob/main/docs/getting-started.md"
_DOCS_TESTING = "https://github.com/ARPAHLS/aura/blob/main/docs/TESTING.md"
+_DOCS_IDENTITY = "https://github.com/ARPAHLS/aura/blob/main/integrations/identity/README.md"
HELP_GROUPS: List[Tuple[str, List[Tuple[str, str]], str]] = [
(
@@ -41,6 +42,7 @@
("aura paths set-project
", "persist default project directory"),
("aura paths set-storage global|project", "persist storage mode"),
("aura config show", "merged global + project YAML and paths"),
+ ("aura identity show", "operator identity adapter config (optional)"),
("aura --home ", "override AURA_HOME for this invocation"),
("aura --project ", "one-shot project-scoped .aura/ storage"),
],
@@ -72,6 +74,7 @@
"aura agent set demo-bot --ref acme/demo --purpose compliance",
"aura agent list",
"aura config show",
+ "aura identity show",
"aura paths set-project .",
"aura run cli-runner path/to/script.py",
"aura export aura_sess_01H...",
diff --git a/aura/cli/main.py b/aura/cli/main.py
index f36c885..c501076 100644
--- a/aura/cli/main.py
+++ b/aura/cli/main.py
@@ -118,6 +118,10 @@ def build_parser() -> argparse.ArgumentParser:
compare_p.add_argument("session_a", help="First session id")
compare_p.add_argument("session_b", help="Second session id")
+ identity_p = sub.add_parser("identity", help="Operator identity adapters and config")
+ identity_sub = identity_p.add_subparsers(dest="identity_command")
+ identity_sub.add_parser("show", help="Show merged identity configuration")
+
verify_p = sub.add_parser("verify", help="Verify exported session data")
verify_sub = verify_p.add_subparsers(dest="verify_command")
chain_p = verify_sub.add_parser("chain", help="Validate a JSONL audit hash chain")
@@ -160,6 +164,11 @@ def dispatch(args: argparse.Namespace) -> int:
return commands.cmd_export_otel(args.session_id)
if args.command == "compare":
return commands.cmd_compare(args.session_a, args.session_b)
+ if args.command == "identity":
+ if args.identity_command == "show" or args.identity_command is None:
+ return commands.cmd_identity_show()
+ print("usage: aura identity show", file=sys.stderr)
+ return 1
if args.command == "verify":
if args.verify_command == "chain":
return commands.cmd_verify_chain(args.path)
diff --git a/aura/config.py b/aura/config.py
index 31d18a2..c0e25d9 100644
--- a/aura/config.py
+++ b/aura/config.py
@@ -17,6 +17,9 @@
"storage": "global", # global | project
"default_session_mode": "script",
"export_on_close": True,
+ "identity_required": False,
+ "identity_export_pii": False,
+ "identity_redact_fields": ["email", "name", "phone"],
}
diff --git a/aura/core/session.py b/aura/core/session.py
index e1c3204..79b7171 100644
--- a/aura/core/session.py
+++ b/aura/core/session.py
@@ -24,6 +24,8 @@
ConstraintViolation,
)
from aura.core.spine import AuditSpine
+from aura.identity.bind import IdentityOptions, bind_operator_identity
+from aura.identity.models import OperatorIdentity
from aura.membrane.ingress import ingress_event_payload
from aura.observers.base import Observer, get_registry
@@ -61,6 +63,8 @@ class Session:
_goal_reached: bool = False
_declared_rules: list[dict[str, Any]] = field(default_factory=list)
_open_snapshot_hash: str | None = None
+ _operator_identity: OperatorIdentity | None = None
+ _identity_ids_overlay: dict[str, Any] = field(default_factory=dict)
def __setattr__(self, name: str, value: Any) -> None:
if (
@@ -87,7 +91,12 @@ def _ensure_active(self) -> None:
if not self._open or not self.spine:
raise SessionNotOpenError(self.session_id)
- def open(self, sessions_dir: Path) -> None:
+ def open(
+ self,
+ sessions_dir: Path,
+ *,
+ identity_options: IdentityOptions | None = None,
+ ) -> None:
if self._closed:
raise SessionClosedError(self.session_id)
if self._open:
@@ -95,6 +104,9 @@ def open(self, sessions_dir: Path) -> None:
self.snapshot_hash = _snapshot_hash(self.profile, self.rules)
self._open_snapshot_hash = self.snapshot_hash
self._declared_rules = copy.deepcopy(self.rules)
+
+ bind_operator_identity(self, identity_options)
+
self._log_path = sessions_dir / f"{self.session_id}.jsonl"
self.spine = AuditSpine(
session_id=self.session_id,
@@ -117,6 +129,26 @@ def open(self, sessions_dir: Path) -> None:
"agent_ref": self.profile.agent_ref,
},
)
+ if self._operator_identity:
+ self.emit("identity.bound", self._operator_identity.bind_payload())
+
+ def agent_ids_trailer(self) -> dict[str, Any]:
+ """Profile trailer merged with session-scoped operator identity."""
+ base = self.profile.id_trailer()
+ if not self._identity_ids_overlay:
+ return base
+ ids = dict(base.get("ids") or {})
+ for key, value in self._identity_ids_overlay.items():
+ if isinstance(ids.get(key), dict) and isinstance(value, dict):
+ ids[key] = {**ids[key], **value}
+ else:
+ ids[key] = value
+ base["ids"] = ids
+ return base
+
+ @property
+ def operator_identity(self) -> OperatorIdentity | None:
+ return self._operator_identity
def _attach_profile_observers(self) -> None:
for entry in self.profile.observers:
@@ -194,21 +226,21 @@ def emit(
"rule": exc.rule,
"pending_event": {"kind": kind, "payload": payload or {}},
},
- agent_ids=self.profile.id_trailer(),
+ agent_ids=self.agent_ids_trailer(),
)
raise
except ConstraintViolation as exc:
self.spine.append(
"constraint.violated",
{"message": str(exc), "rule": exc.rule, "event": exc.event},
- agent_ids=self.profile.id_trailer(),
+ agent_ids=self.agent_ids_trailer(),
)
raise
event = self.spine.append(
kind,
payload or {},
- agent_ids=self.profile.id_trailer(),
+ agent_ids=self.agent_ids_trailer(),
task_id=self.task_id,
step_id=step_id,
)
@@ -216,7 +248,7 @@ def emit(
self.spine.append(
"constraint.passed",
{"results": constraint_results, "for_event": event.event_id},
- agent_ids=self.profile.id_trailer(),
+ agent_ids=self.agent_ids_trailer(),
)
self._dispatch_observers(event.to_dict())
return event.to_dict()
@@ -241,7 +273,7 @@ def require_approval(
"message": message,
"rule": rule,
},
- agent_ids=self.profile.id_trailer(),
+ agent_ids=self.agent_ids_trailer(),
step_id=step_id,
)
raise ApprovalRequired(request_id, message, rule)
@@ -251,12 +283,15 @@ def approve(self, request_id: str, *, principal: str | None = None) -> None:
self._approved.add(request_id)
if self.spine:
payload: dict[str, Any] = {"request_id": request_id}
- if principal:
- payload["principal"] = principal
+ effective_principal = principal
+ if effective_principal is None and self._operator_identity:
+ effective_principal = self._operator_identity.subject
+ if effective_principal:
+ payload["principal"] = effective_principal
self.spine.append(
"constraint.approved",
payload,
- agent_ids=self.profile.id_trailer(),
+ agent_ids=self.agent_ids_trailer(),
)
def _dispatch_observers(self, event: dict[str, Any]) -> None:
diff --git a/aura/exporters/jsonl.py b/aura/exporters/jsonl.py
index 8bccb13..0bd8e98 100644
--- a/aura/exporters/jsonl.py
+++ b/aura/exporters/jsonl.py
@@ -10,8 +10,9 @@
from aura.core.audit_report import AuditReport, AuditReportBuilder
from aura.core.conformance import ConformanceEngine, ConformanceReport
from aura.core.errors import ExportError
+from aura.identity.redaction import redact_summary
from aura.core.session import Session
-from aura.exporters.otel import export_otel_jsonl
+from aura.exporters.otel import export_otel_jsonl, redact_events_for_export
from aura.core.spine import AuditSpine
@@ -39,7 +40,7 @@ def build_session_summary(
policy_version=session.profile.policy_version,
)
- return {
+ summary = {
"session_id": session.session_id,
"aura_id": session.profile.aura_id,
"agent_ref": session.profile.agent_ref,
@@ -49,13 +50,17 @@ def build_session_summary(
"snapshot_hash": session.snapshot_hash,
"open_snapshot_hash": session.open_snapshot_hash,
"trace_id": session.trace_id,
- "agent_ids": session.profile.id_trailer(),
+ "agent_ids": session.agent_ids_trailer(),
+ "identity": (
+ session.operator_identity.to_operator_dict() if session.operator_identity else None
+ ),
"purpose": session.profile.purpose,
"conformance": conformance.to_dict() if conformance else None,
"audit_report": audit_report.to_dict() if audit_report else None,
"event_count": len(session.spine.stream()) if session.spine else 0,
"log": str(session.log_path) if session.log_path else None,
}
+ return redact_summary(summary)
def _atomic_replace(staging_path: Path, final_path: Path) -> None:
@@ -70,7 +75,7 @@ def _write_staging_json(path: Path, payload: dict[str, Any]) -> None:
def _write_staging_otel(session_id: str, sessions_dir: Path, staging_path: Path) -> None:
log_path = sessions_dir / f"{session_id}.jsonl"
events = AuditSpine.read_jsonl(log_path)
- export_otel_jsonl(events, staging_path)
+ export_otel_jsonl(redact_events_for_export(events), staging_path)
def export_session(
diff --git a/aura/exporters/otel.py b/aura/exporters/otel.py
index 7d52f83..0fb73f5 100644
--- a/aura/exporters/otel.py
+++ b/aura/exporters/otel.py
@@ -44,9 +44,33 @@ def _promoted_attributes(event: dict[str, Any]) -> dict[str, Any]:
if step_id:
attrs["aura.step_id"] = str(step_id)
+ ids = agent_ids.get("ids") if isinstance(agent_ids.get("ids"), dict) else {}
+ operator = ids.get("operator") if isinstance(ids, dict) else None
+ if isinstance(operator, dict):
+ if operator.get("verified") is not None:
+ attrs["aura.operator.verified"] = str(operator["verified"]).lower()
+ if operator.get("method"):
+ attrs["aura.operator.method"] = str(operator["method"])
+ if operator.get("subject"):
+ attrs["aura.operator.subject"] = str(operator["subject"])
+ if operator.get("subject_hash"):
+ attrs["aura.operator.subject_hash"] = str(operator["subject_hash"])
+
return attrs
+def redact_events_for_export(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ from aura.identity.redaction import redact_agent_ids
+
+ redacted: list[dict[str, Any]] = []
+ for event in events:
+ copy_event = dict(event)
+ if "agent_ids" in copy_event:
+ copy_event["agent_ids"] = redact_agent_ids(copy_event.get("agent_ids"))
+ redacted.append(copy_event)
+ return redacted
+
+
def events_to_spans(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
spans: list[dict[str, Any]] = []
for event in events:
@@ -83,6 +107,6 @@ def export_otel_jsonl(events: list[dict[str, Any]], out_path: Path) -> Path:
def export_session_otel(session_id: str, sessions_dir: Path) -> Path:
log_path = sessions_dir / f"{session_id}.jsonl"
- events = AuditSpine.read_jsonl(log_path)
+ events = redact_events_for_export(AuditSpine.read_jsonl(log_path))
out_path = sessions_dir / f"{session_id}.otel.jsonl"
return export_otel_jsonl(events, out_path)
diff --git a/aura/identity/__init__.py b/aura/identity/__init__.py
new file mode 100644
index 0000000..607aac7
--- /dev/null
+++ b/aura/identity/__init__.py
@@ -0,0 +1,20 @@
+"""Optional verified operator identity adapters."""
+
+from aura.identity.bind import IdentityOptions, bind_operator_identity, resolve_operator_identity
+from aura.identity.errors import IdentityRequiredError, IdentityVerificationError
+from aura.identity.models import OperatorIdentity
+from aura.identity.protocol import IdentityContext, OperatorIdentityAdapter
+from aura.identity.redaction import redact_agent_ids, redact_summary
+
+__all__ = [
+ "IdentityOptions",
+ "IdentityContext",
+ "OperatorIdentity",
+ "OperatorIdentityAdapter",
+ "bind_operator_identity",
+ "resolve_operator_identity",
+ "IdentityRequiredError",
+ "IdentityVerificationError",
+ "redact_agent_ids",
+ "redact_summary",
+]
diff --git a/aura/identity/adapters/__init__.py b/aura/identity/adapters/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/aura/identity/adapters/auth0.py b/aura/identity/adapters/auth0.py
new file mode 100644
index 0000000..052fcb6
--- /dev/null
+++ b/aura/identity/adapters/auth0.py
@@ -0,0 +1,46 @@
+"""Auth0 OIDC adapter — issuer derived from tenant domain."""
+
+from __future__ import annotations
+
+import os
+
+from aura.identity.adapters.oidc import OidcIdentityAdapter
+from aura.identity.models import OperatorIdentity
+from aura.identity.protocol import IdentityContext
+
+
+class Auth0IdentityAdapter:
+ method = "auth0"
+
+ def __init__(self) -> None:
+ self._oidc = OidcIdentityAdapter()
+
+ def resolve(self, context: IdentityContext) -> OperatorIdentity | None:
+ cfg = dict(context.config)
+ adapter_name = str(cfg.get("adapter") or cfg.get("method") or "")
+ if adapter_name not in ("", "auth0"):
+ return None
+
+ domain = cfg.get("domain") or context.env.get("AURA_AUTH0_DOMAIN")
+ if domain and not cfg.get("issuer"):
+ host = str(domain).rstrip("/")
+ if not host.startswith("http"):
+ host = f"https://{host}"
+ cfg["issuer"] = host if host.endswith("/") else f"{host}/"
+ if not cfg.get("audience"):
+ cfg["audience"] = cfg.get("client_id") or context.env.get("AURA_AUTH0_AUDIENCE")
+ if not cfg.get("token"):
+ cfg["token"] = context.env.get("AURA_AUTH0_TOKEN") or os.environ.get("AURA_OIDC_TOKEN")
+
+ merged = IdentityContext(
+ session_id=context.session_id,
+ aura_id=context.aura_id,
+ agent_ref=context.agent_ref,
+ profile_ids=context.profile_ids,
+ config={**cfg, "adapter": "oidc", "method": "oidc"},
+ env=context.env,
+ )
+ identity = self._oidc.resolve(merged)
+ if identity is not None:
+ identity.method = self.method
+ return identity
diff --git a/aura/identity/adapters/manual.py b/aura/identity/adapters/manual.py
new file mode 100644
index 0000000..ccb458b
--- /dev/null
+++ b/aura/identity/adapters/manual.py
@@ -0,0 +1,43 @@
+"""Manual operator identity from profile ids or session override."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from aura.identity.models import OperatorIdentity
+from aura.identity.protocol import IdentityContext
+
+
+def operator_from_mapping(
+ data: dict[str, Any], *, default_method: str = "manual"
+) -> OperatorIdentity | None:
+ if not data:
+ return None
+ subject = data.get("subject") or data.get("id") or data.get("email")
+ if not subject:
+ return None
+ verified = bool(data.get("verified", False))
+ method = str(data.get("method") or default_method)
+ return OperatorIdentity(
+ verified=verified,
+ method=method,
+ subject=str(subject),
+ email=data.get("email"),
+ name=data.get("name"),
+ session_ref=data.get("session_ref"),
+ issuer=data.get("issuer"),
+ )
+
+
+class ManualIdentityAdapter:
+ method = "manual"
+
+ def resolve(self, context: IdentityContext) -> OperatorIdentity | None:
+ override = context.config.get("operator")
+ if isinstance(override, dict):
+ return operator_from_mapping(override)
+ profile_ids = context.profile_ids or {}
+ operator = profile_ids.get("operator")
+ if isinstance(operator, dict):
+ return operator_from_mapping(operator)
+ return None
diff --git a/aura/identity/adapters/mock.py b/aura/identity/adapters/mock.py
new file mode 100644
index 0000000..0d260f2
--- /dev/null
+++ b/aura/identity/adapters/mock.py
@@ -0,0 +1,39 @@
+"""Mock verified operator for CI and local demos."""
+
+from __future__ import annotations
+
+from aura.identity.models import OperatorIdentity
+from aura.identity.protocol import IdentityContext
+
+
+class MockIdentityAdapter:
+ method = "mock"
+
+ def __init__(
+ self,
+ *,
+ subject: str = "mock-operator",
+ email: str | None = "operator@example.com",
+ verified: bool = True,
+ ) -> None:
+ self._subject = subject
+ self._email = email
+ self._verified = verified
+
+ def resolve(self, context: IdentityContext) -> OperatorIdentity | None:
+ cfg = context.config
+ if cfg.get("adapter") not in (None, "mock", self.method):
+ return None
+ if not cfg.get("enabled", True):
+ return None
+ subject = str(
+ cfg.get("subject") or context.env.get("AURA_MOCK_OPERATOR_SUBJECT") or self._subject
+ )
+ email = cfg.get("email") or context.env.get("AURA_MOCK_OPERATOR_EMAIL") or self._email
+ return OperatorIdentity(
+ verified=bool(cfg.get("verified", self._verified)),
+ method=self.method,
+ subject=subject,
+ email=str(email) if email else None,
+ session_ref=context.session_id,
+ )
diff --git a/aura/identity/adapters/oidc.py b/aura/identity/adapters/oidc.py
new file mode 100644
index 0000000..979d3a1
--- /dev/null
+++ b/aura/identity/adapters/oidc.py
@@ -0,0 +1,111 @@
+"""OIDC / JWT bearer operator identity."""
+
+from __future__ import annotations
+
+import json
+import urllib.error
+import urllib.request
+from typing import Any
+
+from aura.identity.errors import IdentityVerificationError
+from aura.identity.models import OperatorIdentity
+from aura.identity.protocol import IdentityContext
+
+
+def _decode_jwt_unverified(token: str) -> dict[str, Any]:
+ parts = token.split(".")
+ if len(parts) < 2:
+ raise IdentityVerificationError("malformed JWT", method="oidc")
+ payload = parts[1]
+ padding = "=" * (-len(payload) % 4)
+ import base64
+
+ raw = base64.urlsafe_b64decode(payload + padding)
+ data = json.loads(raw.decode("utf-8"))
+ if not isinstance(data, dict):
+ raise IdentityVerificationError("JWT payload must be an object", method="oidc")
+ return data
+
+
+def _verify_jwt(token: str, *, issuer: str, audience: str | None) -> dict[str, Any]:
+ try:
+ import jwt
+ from jwt import PyJWKClient
+ except ImportError as exc: # pragma: no cover - optional extra
+ raise IdentityVerificationError(
+ "pyjwt required for OIDC verification (pip install 'aura-harness[identity]')",
+ method="oidc",
+ ) from exc
+
+ jwks_url = issuer.rstrip("/") + "/.well-known/openid-configuration"
+ try:
+ with urllib.request.urlopen(jwks_url, timeout=10) as resp:
+ meta = json.loads(resp.read().decode("utf-8"))
+ except (urllib.error.URLError, json.JSONDecodeError) as exc:
+ raise IdentityVerificationError(
+ f"failed to load OIDC metadata: {exc}", method="oidc"
+ ) from exc
+
+ jwks_uri = meta.get("jwks_uri")
+ if not jwks_uri:
+ raise IdentityVerificationError("OIDC metadata missing jwks_uri", method="oidc")
+
+ client = PyJWKClient(jwks_uri)
+ signing_key = client.get_signing_key_from_jwt(token)
+ options = {"verify_aud": audience is not None}
+ return jwt.decode(
+ token,
+ signing_key.key,
+ algorithms=["RS256", "ES256", "PS256"],
+ issuer=issuer,
+ audience=audience,
+ options=options,
+ )
+
+
+class OidcIdentityAdapter:
+ method = "oidc"
+
+ def resolve(self, context: IdentityContext) -> OperatorIdentity | None:
+ cfg = context.config
+ adapter_name = str(cfg.get("adapter") or cfg.get("method") or "")
+ if adapter_name not in ("", "oidc", "jwt"):
+ return None
+
+ token = (
+ cfg.get("token")
+ or context.env.get("AURA_OIDC_TOKEN")
+ or context.env.get("AURA_IDENTITY_TOKEN")
+ )
+ if not token:
+ return None
+
+ issuer = cfg.get("issuer") or context.env.get("AURA_OIDC_ISSUER")
+ audience = cfg.get("audience") or context.env.get("AURA_OIDC_AUDIENCE")
+ verify = bool(cfg.get("verify_signature", True))
+
+ if verify:
+ if not issuer:
+ raise IdentityVerificationError(
+ "issuer required for OIDC verification", method="oidc"
+ )
+ claims = _verify_jwt(
+ str(token), issuer=str(issuer), audience=str(audience) if audience else None
+ )
+ else:
+ claims = _decode_jwt_unverified(str(token))
+
+ subject = claims.get("sub")
+ if not subject:
+ raise IdentityVerificationError("JWT missing sub claim", method="oidc")
+
+ return OperatorIdentity(
+ verified=verify,
+ method=self.method,
+ subject=str(subject),
+ email=claims.get("email"),
+ name=claims.get("name") or claims.get("preferred_username"),
+ session_ref=context.session_id,
+ issuer=str(issuer or claims.get("iss") or ""),
+ claims={k: claims[k] for k in ("sub", "iss", "aud", "email") if k in claims},
+ )
diff --git a/aura/identity/bind.py b/aura/identity/bind.py
new file mode 100644
index 0000000..fbee957
--- /dev/null
+++ b/aura/identity/bind.py
@@ -0,0 +1,98 @@
+"""Bind operator identity at session open."""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass, field
+from typing import Any
+
+from aura.config import get_config
+from aura.identity.errors import IdentityRequiredError
+from aura.identity.models import OperatorIdentity
+from aura.identity.protocol import IdentityContext, OperatorIdentityAdapter
+from aura.identity.registry import adapter_chain_from_config
+
+
+@dataclass
+class IdentityOptions:
+ """Per-session identity resolution inputs."""
+
+ adapter: OperatorIdentityAdapter | None = None
+ operator: dict[str, Any] | None = None
+ config: dict[str, Any] = field(default_factory=dict)
+
+
+def _merged_identity_config(options: IdentityOptions | None) -> dict[str, Any]:
+ cfg = get_config().values
+ merged: dict[str, Any] = {}
+ identity_cfg = cfg.get("identity")
+ if isinstance(identity_cfg, dict):
+ merged.update(identity_cfg)
+ if options and options.config:
+ merged.update(options.config)
+ if options and options.operator:
+ merged["operator"] = options.operator
+ return merged
+
+
+def _identity_from_profile_types(types: list[dict[str, Any]] | None) -> dict[str, Any]:
+ if not types:
+ return {}
+ for entry in types:
+ if not isinstance(entry, dict):
+ continue
+ if entry.get("role") == "identity":
+ config = entry.get("config")
+ if isinstance(config, dict):
+ out = dict(config)
+ if entry.get("type_id"):
+ out.setdefault("type_id", entry["type_id"])
+ return out
+ return {}
+
+
+def resolve_operator_identity(
+ session: Any,
+ options: IdentityOptions | None = None,
+) -> OperatorIdentity | None:
+ """Resolve operator without mutating session (for pre-spine validation)."""
+ profile = session.profile
+ merged = _merged_identity_config(options)
+ merged.update(_identity_from_profile_types(getattr(profile, "types", None)))
+
+ context = IdentityContext(
+ session_id=session.session_id,
+ aura_id=profile.aura_id,
+ agent_ref=profile.agent_ref,
+ profile_ids=dict(profile.ids or {}),
+ config=merged,
+ env=dict(os.environ),
+ )
+
+ if options and options.adapter is not None:
+ return options.adapter.resolve(context)
+
+ for adapter in adapter_chain_from_config(merged):
+ identity = adapter.resolve(context)
+ if identity is not None:
+ return identity
+ return None
+
+
+def bind_operator_identity(
+ session: Any, options: IdentityOptions | None = None
+) -> OperatorIdentity | None:
+ """Attach operator to session; does not emit spine events."""
+ identity = resolve_operator_identity(session, options)
+ required = bool(get_config().values.get("identity_required", False))
+ merged = _merged_identity_config(options)
+ if merged.get("required") is True:
+ required = True
+
+ if identity is None and required:
+ raise IdentityRequiredError(session.session_id)
+
+ if identity is not None:
+ session._operator_identity = identity
+ session._identity_ids_overlay = {"operator": identity.to_operator_dict()}
+ return identity
diff --git a/aura/identity/errors.py b/aura/identity/errors.py
new file mode 100644
index 0000000..86f5bd9
--- /dev/null
+++ b/aura/identity/errors.py
@@ -0,0 +1,22 @@
+"""Identity resolution errors."""
+
+
+class IdentityError(Exception):
+ """Base class for identity adapter errors."""
+
+
+class IdentityRequiredError(IdentityError):
+ """Session requires verified operator identity but none was resolved."""
+
+ def __init__(self, session_id: str | None = None) -> None:
+ sid = session_id or "unknown"
+ super().__init__(f"Verified operator identity required for session {sid}")
+
+
+class IdentityVerificationError(IdentityError):
+ """Token or adapter verification failed."""
+
+ def __init__(self, message: str, *, method: str | None = None) -> None:
+ self.method = method
+ prefix = f"{method}: " if method else ""
+ super().__init__(f"{prefix}{message}")
diff --git a/aura/identity/models.py b/aura/identity/models.py
new file mode 100644
index 0000000..54410e3
--- /dev/null
+++ b/aura/identity/models.py
@@ -0,0 +1,48 @@
+"""Operator identity models."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from hashlib import sha256
+from typing import Any
+
+
+def hash_subject(subject: str) -> str:
+ return sha256(subject.encode("utf-8")).hexdigest()[:16]
+
+
+@dataclass
+class OperatorIdentity:
+ """Verified or declared operator attached to a session."""
+
+ verified: bool
+ method: str
+ subject: str
+ email: str | None = None
+ name: str | None = None
+ session_ref: str | None = None
+ issuer: str | None = None
+ claims: dict[str, Any] = field(default_factory=dict)
+
+ def to_operator_dict(self) -> dict[str, Any]:
+ data: dict[str, Any] = {
+ "verified": self.verified,
+ "method": self.method,
+ "subject": self.subject,
+ "subject_hash": hash_subject(self.subject),
+ }
+ if self.email:
+ data["email"] = self.email
+ if self.name:
+ data["name"] = self.name
+ if self.session_ref:
+ data["session_ref"] = self.session_ref
+ if self.issuer:
+ data["issuer"] = self.issuer
+ return data
+
+ def bind_payload(self) -> dict[str, Any]:
+ payload = {"operator": self.to_operator_dict()}
+ if self.claims:
+ payload["claim_keys"] = sorted(self.claims.keys())
+ return payload
diff --git a/aura/identity/protocol.py b/aura/identity/protocol.py
new file mode 100644
index 0000000..90d1016
--- /dev/null
+++ b/aura/identity/protocol.py
@@ -0,0 +1,29 @@
+"""Operator identity adapter protocol."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Protocol
+
+from aura.identity.models import OperatorIdentity
+
+
+@dataclass
+class IdentityContext:
+ """Inputs available when resolving operator identity at session open."""
+
+ session_id: str
+ aura_id: str
+ agent_ref: str | None
+ profile_ids: dict[str, Any] = field(default_factory=dict)
+ config: dict[str, Any] = field(default_factory=dict)
+ env: dict[str, str] = field(default_factory=dict)
+
+
+class OperatorIdentityAdapter(Protocol):
+ """Pluggable backend — Auth0, OIDC, manual, mock, corporate SSO."""
+
+ method: str
+
+ def resolve(self, context: IdentityContext) -> OperatorIdentity | None:
+ """Return operator identity or None when this adapter does not apply."""
diff --git a/aura/identity/redaction.py b/aura/identity/redaction.py
new file mode 100644
index 0000000..5f48785
--- /dev/null
+++ b/aura/identity/redaction.py
@@ -0,0 +1,68 @@
+"""Redact operator PII for export surfaces."""
+
+from __future__ import annotations
+
+import copy
+from typing import Any
+
+from aura.config import get_config
+
+DEFAULT_REDACT_FIELDS = frozenset({"email", "name", "phone"})
+
+
+def identity_export_settings() -> tuple[bool, frozenset[str]]:
+ cfg = get_config().values
+ export_pii = bool(cfg.get("identity_export_pii", False))
+ raw = cfg.get("identity_redact_fields")
+ if isinstance(raw, list):
+ fields = frozenset(str(x) for x in raw)
+ else:
+ fields = DEFAULT_REDACT_FIELDS
+ return export_pii, fields
+
+
+def redact_operator_dict(
+ operator: dict[str, Any], *, export_pii: bool, fields: frozenset[str]
+) -> dict[str, Any]:
+ if export_pii or not operator:
+ return dict(operator)
+ redacted = dict(operator)
+ for key in fields:
+ if key in redacted:
+ redacted[key] = None
+ if "subject" in redacted and "subject_hash" not in redacted:
+ from aura.identity.models import hash_subject
+
+ redacted["subject_hash"] = hash_subject(str(redacted["subject"]))
+ return redacted
+
+
+def redact_agent_ids(agent_ids: dict[str, Any] | None) -> dict[str, Any]:
+ if not agent_ids:
+ return {}
+ export_pii, fields = identity_export_settings()
+ if export_pii:
+ return dict(agent_ids)
+ out = copy.deepcopy(agent_ids)
+ ids = out.get("ids")
+ if isinstance(ids, dict) and isinstance(ids.get("operator"), dict):
+ ids["operator"] = redact_operator_dict(
+ ids["operator"], export_pii=export_pii, fields=fields
+ )
+ return out
+
+
+def redact_summary(summary: dict[str, Any]) -> dict[str, Any]:
+ export_pii, fields = identity_export_settings()
+ if export_pii:
+ return summary
+ out = copy.deepcopy(summary)
+ agent_ids = out.get("agent_ids")
+ if isinstance(agent_ids, dict):
+ out["agent_ids"] = redact_agent_ids(agent_ids)
+ identity = out.get("identity")
+ if isinstance(identity, dict) and isinstance(identity.get("operator"), dict):
+ identity["operator"] = redact_operator_dict(
+ identity["operator"], export_pii=export_pii, fields=fields
+ )
+ return out
diff --git a/aura/identity/registry.py b/aura/identity/registry.py
new file mode 100644
index 0000000..64b3fcc
--- /dev/null
+++ b/aura/identity/registry.py
@@ -0,0 +1,64 @@
+"""Identity adapter registry."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from aura.identity.adapters.auth0 import Auth0IdentityAdapter
+from aura.identity.adapters.manual import ManualIdentityAdapter
+from aura.identity.adapters.mock import MockIdentityAdapter
+from aura.identity.adapters.oidc import OidcIdentityAdapter
+from aura.identity.protocol import OperatorIdentityAdapter
+
+_BUILTIN_ADAPTERS: dict[str, OperatorIdentityAdapter] = {
+ "manual": ManualIdentityAdapter(),
+ "mock": MockIdentityAdapter(),
+ "oidc": OidcIdentityAdapter(),
+ "jwt": OidcIdentityAdapter(),
+ "auth0": Auth0IdentityAdapter(),
+}
+
+
+def get_builtin_adapter(name: str) -> OperatorIdentityAdapter | None:
+ return _BUILTIN_ADAPTERS.get(name)
+
+
+def _append_unique(chain: list[OperatorIdentityAdapter], adapter: OperatorIdentityAdapter) -> None:
+ if not any(existing.method == adapter.method for existing in chain):
+ chain.append(adapter)
+
+
+def adapter_chain_from_config(config: dict[str, Any] | None) -> list[OperatorIdentityAdapter]:
+ if not config:
+ return [ManualIdentityAdapter()]
+
+ chain: list[OperatorIdentityAdapter] = []
+ explicit = config.get("adapter") or config.get("method")
+ if explicit:
+ built = get_builtin_adapter(str(explicit))
+ if built:
+ _append_unique(chain, built)
+
+ for entry in config.get("adapters") or []:
+ if isinstance(entry, str):
+ built = get_builtin_adapter(entry)
+ if built:
+ _append_unique(chain, built)
+ elif isinstance(entry, dict):
+ name = entry.get("adapter") or entry.get("method")
+ if name:
+ built = get_builtin_adapter(str(name))
+ if built:
+ _append_unique(chain, built)
+
+ if not chain:
+ if config.get("token") or config.get("issuer") or config.get("domain"):
+ for name in ("auth0", "oidc"):
+ built = get_builtin_adapter(name)
+ if built:
+ _append_unique(chain, built)
+ elif config.get("enabled") is True or config.get("subject"):
+ _append_unique(chain, MockIdentityAdapter())
+
+ _append_unique(chain, ManualIdentityAdapter())
+ return chain
diff --git a/aura/observers/presets/break_observer.py b/aura/observers/presets/break_observer.py
index db5889b..f7b7dcc 100644
--- a/aura/observers/presets/break_observer.py
+++ b/aura/observers/presets/break_observer.py
@@ -67,7 +67,7 @@ def _emit_alert(self, alert_type: str, detail: dict[str, Any]) -> None:
spine.append(
"observer.alert",
alert,
- agent_ids=self._session.profile.id_trailer(),
+ agent_ids=self._session.agent_ids_trailer(),
)
diff --git a/aura/observers/presets/monitor.py b/aura/observers/presets/monitor.py
index e579155..86554d2 100644
--- a/aura/observers/presets/monitor.py
+++ b/aura/observers/presets/monitor.py
@@ -75,7 +75,7 @@ def _append_note(self, note_type: str, detail: dict[str, Any]) -> None:
spine.append(
"observer.note",
note,
- agent_ids=self._session.profile.id_trailer(),
+ agent_ids=self._session.agent_ids_trailer(),
)
self._write_log_line(note)
diff --git a/docs/INDEX.md b/docs/INDEX.md
index 90404b3..2fabf13 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -13,7 +13,7 @@ Single entry point for public docs. **Shipped behavior** lives in Tier 1–2; Ti
| [using-aura.md](using-aura.md) | Membrane, postures, session export, SDK |
| [concepts.md](concepts.md) | Agent, session, identity, audit |
| [examples/README.md](../examples/README.md) | Runnable core scripts + integration demos |
-| [integrations/README.md](integrations/README.md) | Pick your stack — Ollama, cloud APIs, Skillware |
+| [integrations/README.md](integrations/README.md) | Pick your stack — Ollama, cloud APIs, Skillware, operator identity |
| [guides/reference-tool-host-capstone.md](guides/reference-tool-host-capstone.md) | ToolHost checklist (mock → live → Ollama) |
---
@@ -28,6 +28,7 @@ Single entry point for public docs. **Shipped behavior** lives in Tier 1–2; Ti
| [skillware-integration.md](skillware-integration.md) | Skillware reference adapter (optional host) |
| [guides/aura-on-skillware.md](guides/aura-on-skillware.md) | Deep dive — Skillware as one ToolHost impl |
| [trust-paths.md](trust-paths.md) | `agent_ref`, ULID, ids trailer — no central ID service |
+| [../integrations/identity/README.md](../integrations/identity/README.md) | Optional verified operator adapters (OIDC, Auth0, BYO) |
| [outputs.md](outputs.md) | JSONL, summary, audit report, hash chain, OTel |
| [TESTING.md](TESTING.md) | pytest, black, flake8, PR checklist |
| [../CONTRIBUTING.md](../CONTRIBUTING.md) | Contributor guide (humans and agents) |
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 0ec8607..b98fe6a 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -12,6 +12,7 @@ Shipped work stays in [CHANGELOG.md](../CHANGELOG.md). This file lists what is *
| **v0.2** | Membrane, sequencer, Skillware host, observers |
| **v0.3** | ULID + `agent_ref`, audit report, hash chain, OTel export (+ promoted attrs), compare CLI, ToolHost reference coat |
| **v0.3.4** | Onboarding guide, `report show`, flat core examples, Monitor/Break presets, capstone + examples 05–08 |
+| **Unreleased** | Verified operator identity adapters ([#55](https://github.com/ARPAHLS/aura/issues/55)), session lifecycle invariants ([#15](https://github.com/ARPAHLS/aura/issues/15)) |
---
@@ -24,9 +25,10 @@ Shipped work stays in [CHANGELOG.md](../CHANGELOG.md). This file lists what is *
| Middleware ops | PII mask, compress — schema exists |
| Signed audit packs | WORM / external sink hooks |
| HTTP fleet API | Remote session management |
+| DID / VC operator adapter | Decentralized identity enrichment (follow-up to #55) |
| Auto-discovery | LangGraph / MCP probe where stable |
-**Shipped in v0.2–v0.3 (reference ToolHost epic):** `ToolHost` protocol, manifest merge at bind, Monitor + Break observer presets, ingress bind enrichment, OTel promoted attributes, sequencer `when`, capstone guide, examples 01–08. Details in [CHANGELOG.md](../CHANGELOG.md) and [reference-tool-host-capstone.md](guides/reference-tool-host-capstone.md).
+**Shipped in v0.2–v0.3 (reference ToolHost epic):** `ToolHost` protocol, manifest merge at bind, Monitor + Break observer presets, ingress bind enrichment, OTel promoted attributes, sequencer `when`, capstone guide, examples 01–09. Details in [CHANGELOG.md](../CHANGELOG.md) and [reference-tool-host-capstone.md](guides/reference-tool-host-capstone.md).
---
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 31b82da..b82ab4f 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -99,16 +99,18 @@ Integration tests **fail** (not skip) if Ollama or Skillware is missing — that
| `test_v03.py` | Identity, audit report, hash chain, compare |
| `test_cli.py` | `aura` CLI commands and exit codes |
| `test_core_gaps.py` | Config, exporters, runtime, middleware, archive, tamper, compare edge cases (GH #4) |
+| `test_identity.py` | Operator identity adapters, redaction, OTel operator attrs, `identity.bound` (GH #55) |
+| `test_session_invariants.py` | Session lifecycle, atomic export, closed-session errors (GH #15) |
| `test_examples_smoke.py` | Runnable example scripts |
## What we test
| Area | Tests |
|---|---|
-| Identity | ULID ids, `agent_ref`, custom `aura_id`, resolve lookup, legacy `AURA-000n`, archive |
+| Identity | ULID ids, `agent_ref`, custom `aura_id`, resolve lookup, legacy `AURA-000n`, archive; optional operator adapters + export redaction (`test_identity.py`) |
| Audit | Hash chain (valid + tamper), audit report, approver principal, session export |
| Core | Registry, spine, constraints, conformance, sequencer (`test_core.py`, `test_v02.py`) |
-| CLI | Version, agent CRUD, run, logs, export, export-otel, compare (`test_cli.py`) |
+| CLI | Version, agent CRUD, run, logs, export, export-otel, compare, identity show (`test_cli.py`) |
| Config / runtime | YAML merge, `run_script`, middleware, session modes (`test_core_gaps.py`) |
| Compare / OTel | Summary diff incl. `agent_ref` + `hash_chain_valid`, OTel JSONL export (`test_v03.py`, `test_core_gaps.py`) |
| Examples | Smoke run all `examples/*.py` (`test_examples_smoke.py`) |
diff --git a/docs/comparison.md b/docs/comparison.md
index 2f69e94..a1b5c6c 100644
--- a/docs/comparison.md
+++ b/docs/comparison.md
@@ -83,7 +83,7 @@ AURA is **not** a guide for building the agent. It is infrastructure for **confi
### Key differences
* **Positioning**: DSH is a **runtime you adopt** — "everything is a plugin" *inside* DSH. AURA is a **wrapper around whatever runtime you already have**.
-* **Identity / accountability chain**: DSH focuses on session integrity; AURA adds `agent_ref` (ULID), audit report, and hash-chained spine events without requiring a central identity service (optional enrichment adapters on roadmap).
+* **Identity / accountability chain**: DSH focuses on session integrity; AURA adds `agent_ref` (ULID), audit report, and hash-chained spine events without requiring a central identity service. Optional **verified operator** adapters (OIDC, Auth0, manual, mock) enrich `ids.operator` when configured ([#55](https://github.com/ARPAHLS/aura/issues/55)).
* **Long term**: DSH competes on composable agent OS; AURA competes on **agnostic governance layer** — including the option to wrap DSH itself inside the cavity.
---
@@ -189,25 +189,26 @@ Orchestrators optimize for **task completion**. Eval harnesses optimize for **qu
---
-## Where AURA is today (v0.3.4)
+## Where AURA is today (v0.3.4+)
Honest scope — reference ToolHost coat, membrane presets, and CLI/docs depth shipped since v0.3.3; full zero-intrusion wiring on every transport still growing:
-| Shipped (through v0.3.4) | Roadmap |
+| Shipped (through v0.3.4+) | Roadmap |
| :--- | :--- |
| Agent registry, sessions, SDK `emit()` | LangGraph / MCP auto-probe |
| Constraint engine on events | Full I/O normalizer for arbitrary transports |
| Audit trail (JSONL) + session export + **audit report** | Signed audit packs |
| **Hash chain** on spine events + compare + **`aura verify chain`** | Network/shell intercept without host cooperation |
-| **`agent_ref` (ULID)** + policy version on export | Verified operator identity |
+| **`agent_ref` (ULID)** + policy version on export | DID / verifiable-credential operator adapter |
+| **Verified operator identity** — optional adapters, `identity.bound`, export redaction ([#55](https://github.com/ARPAHLS/aura/issues/55)) | Gatekeeper pre-session verify ([#43](https://github.com/ARPAHLS/aura/issues/43)) |
| **Ingress** + bind enrichment on `skill.registered` | Branching / parallel sequencer steps |
| **Egress** `guarded_tool_call` + **ToolHost** protocol (Skillware reference coat) | Broader egress adapters |
| **Sequencer** — linear steps, gates, retries, **`when`** skip | |
| **Observers** — Monitor + Break presets | Webhooks, enterprise sinks |
| **Skill manifest merge** at bind | Capability broker |
-| **OTel exporter** + promoted span attributes | HTTP fleet API |
-| **CLI** — `report show`, `agent set`, config/paths, onboarding guide | |
-| **Examples 01–08** (flat layout) + integrations index | Framework host wraps |
+| **OTel exporter** + promoted span attributes (incl. operator) | HTTP fleet API |
+| **CLI** — `report show`, `agent set`, config/paths, `identity show`, onboarding guide | |
+| **Examples 01–09** (flat + integration demos) + integrations index | Framework host wraps |
The **doctrine** is membrane-first: configure gates, rules, and export invariants at the boundary; keep the cavity a black box. v0.3 adds the **receipt** layer (audit report + integrity chain); v0.2 delivered the first egress path via Skillware/mock hosts per [skillware-integration.md](skillware-integration.md) and [sequencer.md](sequencer.md).
diff --git a/docs/concepts.md b/docs/concepts.md
index 1b8f86f..ee2d65a 100644
--- a/docs/concepts.md
+++ b/docs/concepts.md
@@ -11,9 +11,9 @@ A logical entity you run under AURA.
| **`agent_ref`** | Stable slug for humans and CI, e.g. `acme/compliance-bot` |
| **`aura_id`** | Internal ULID (default) or your supplied id |
| **`name`** | Optional alias for lookup |
-| **`ids`** | Trailer for tenant, Skillware, and your external ids |
+| **`ids`** | Trailer for tenant, Skillware, external ids, and optional **`operator`** (manual or verified adapter) |
-Legacy profiles with `AURA-000n` ids still load. See [trust-paths.md](trust-paths.md).
+Legacy profiles with `AURA-000n` ids still load. See [trust-paths.md](trust-paths.md) and [integrations/identity/README.md](../integrations/identity/README.md) for optional verified operator adapters.
Profile fields also include **`skills`**, **`sequencer`** spec, **`observers`**, and **`rules`**.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 4b54549..eca7fee 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -16,6 +16,12 @@ Optional Skillware integration:
pip install -e ".[dev,skillware]"
```
+Optional OIDC/Auth0 operator identity (enterprise):
+
+```bash
+pip install -e ".[dev,identity]"
+```
+
Requires Python 3.10+.
## CLI
@@ -25,6 +31,7 @@ After install, run `aura` with no arguments for the interactive menu (ASCII spla
```bash
aura version
aura config show
+aura identity show
aura agent list
aura agent set my-bot --ref tenant/slug --purpose "experiments"
aura --help
@@ -85,6 +92,7 @@ aura logs aura_sess_xxxxxxxxxxxx
aura export aura_sess_xxxxxxxxxxxx
aura report show aura_sess_xxxxxxxxxxxx
aura report show aura_sess_xxxxxxxxxxxx --json
+aura identity show
aura verify chain ~/.aura/sessions/aura_sess_xxxxxxxxxxxx.jsonl
```
diff --git a/docs/integrations/README.md b/docs/integrations/README.md
index 31a8d36..04983a3 100644
--- a/docs/integrations/README.md
+++ b/docs/integrations/README.md
@@ -8,6 +8,7 @@ Attach AURA to your stack — models, tool runtimes, frameworks, sandboxes.
|---|---|---|
| **Overview** | this page | Start here to find your stack |
| **Skillware** | [`integrations/skillware/`](../../integrations/skillware/) | Reference ToolHost adapter; `[skillware]` extra |
+| **Operator identity** | [`integrations/identity/`](../../integrations/identity/) | Optional OIDC/Auth0/manual/mock adapters; `[identity]` extra |
| **Ollama (local)** | [`integrations/skillware/ollama_skill_loop.py`](../../integrations/skillware/ollama_skill_loop.py) | Dev default: `llama3.2:1b` via `.env` |
| **OpenAI (ChatGPT)** | [`integrations/openai/`](../../integrations/openai/) | Body loop + Skillware egress; `[openai]` extra |
| **Anthropic (Claude)** | [`integrations/anthropic/`](../../integrations/anthropic/) | Body loop + Skillware egress; `[anthropic]` extra |
@@ -26,6 +27,7 @@ Use the project **`.venv`** for installs (`pip install -e ".[integrations]"`), n
| [06-skillware-sequencer-chain](../examples/06-skillware-sequencer-chain/) | Sequencer chain with conditional `when` steps |
| [07-observer-presets](../examples/07-observer-presets/) | Monitor + Break observer presets |
| [08-emit-only-loop](../examples/08-emit-only-loop/) | Emit-only coat — no tool host |
+| [09-operator-identity](../examples/09-operator-identity/) | Optional verified operator trailer (mock adapter) |
| [sequencer_pipeline.py](../examples/sequencer_pipeline.py) | Sequencer with mocks |
## Related docs
diff --git a/docs/onboarding.md b/docs/onboarding.md
index c6d9ca7..0bb9213 100644
--- a/docs/onboarding.md
+++ b/docs/onboarding.md
@@ -65,6 +65,7 @@ aura agent create research-bot \
| **`purpose`** | Declared intent — appears in profile and spine |
| **`rules`** | Constitution — `confirm_before`, `allow_tools`, `deny_tools`, token limits |
| **`ids`** | Your external IDs (company, vendor assistant id) — AURA does not replace them |
+| **`ids.operator`** (optional) | Human/service principal when using an identity adapter — see [integrations/identity](../integrations/identity/README.md) |
Update later: `aura agent set research-bot --ref acme/research-bot --variable model=gpt-4o-mini`.
diff --git a/docs/outputs.md b/docs/outputs.md
index 4f8f9ae..621de0e 100644
--- a/docs/outputs.md
+++ b/docs/outputs.md
@@ -20,6 +20,8 @@ CLI: `aura report show `, `aura report show --json`, `a
**Closed session:** After close, `emit`, `approve`, and a second `close()` raise `SessionClosedError`. `session_id` and `trace_id` are fixed at open. `open_snapshot_hash` captures rules + sequencer at open for conformance; `snapshot_hash` in the summary may update when skills bind at runtime.
+**Spine events (identity):** When an operator adapter binds, the session emits `identity.bound` after `session.open`. Operator fields appear under `agent_ids.ids.operator` on subsequent events. JSONL retains full fields; summary and OTel apply [redaction defaults](../integrations/identity/README.md).
+
---
## Audit report (summary JSON)
@@ -64,6 +66,7 @@ Summary includes `agent_ref`, `aura_id`, `policy_version`, `snapshot_hash`, and
- `aura.agent_ref`, `aura.policy_version` — session identity
- `aura.principal` — approver on gated calls
+- `aura.operator.subject`, `aura.operator.method`, `aura.operator.verified` — session operator when identity adapter bound
- `aura.skill_id` — skill on tool and registration events
→ [trust-paths.md](trust-paths.md) · [aura-event.schema.json](../spec/aura-event.schema.json) · [reference-tool-host-capstone.md](guides/reference-tool-host-capstone.md)
diff --git a/docs/trust-paths.md b/docs/trust-paths.md
index 8aa01b9..b11d452 100644
--- a/docs/trust-paths.md
+++ b/docs/trust-paths.md
@@ -14,6 +14,7 @@ AURA does **not** run a central identity service. Identity is **layered** so hum
| **Run** | `session_id`, `trace_id`, `step_id` | One activation, causal grouping |
| **Policy** | `policy_version` | Label for constitution version at session open |
| **Binding** | `snapshot_hash` | Hash of rules + sequencer at open |
+| **Operator (optional)** | `ids.operator` | Verified or manual human/service principal — see [identity adapter](../integrations/identity/README.md) |
Legacy profiles with `AURA-000n` ids still load.
@@ -33,4 +34,6 @@ Each run gets `aura_sess_*`. Events carry `agent_ids` trailer (ref, policy versi
Optional future adapters may add more fields — core behavior is unchanged when they are absent.
+**Operator identity ([#55](https://github.com/ARPAHLS/aura/issues/55)):** optional adapter at session open adds `ids.operator` to every event's `agent_ids` trailer and emits `identity.bound`. Configure via profile `types` (`role: identity`), `configure(identity={...})`, or `session(identity_adapter=...)`. Export redacts PII by default.
+
→ [concepts.md](concepts.md) · [outputs.md](outputs.md)
diff --git a/docs/using-aura.md b/docs/using-aura.md
index c529a8c..253bfb9 100644
--- a/docs/using-aura.md
+++ b/docs/using-aura.md
@@ -102,6 +102,7 @@ with ag.session(mode="task") as run:
| `agent(name)` | Get/create agent profile |
| `agent.session()` | Open session, auto-export on close |
| `run.summary` / `run.audit_report` | In-memory receipt (always built; disk write optional via `export=`) |
+| `session(identity_adapter=...)` / `operator=` | Optional verified operator trailer ([#55](https://github.com/ARPAHLS/aura/issues/55)) |
| `run.emit(kind, payload)` | Append audited event |
| `run.approve(request_id, principal="operator@corp")` | Satisfy confirm/gate and record the approver |
| `run.run_sequencer(host=...)` | Run declared step pipeline |
diff --git a/examples/09-operator-identity/main.py b/examples/09-operator-identity/main.py
new file mode 100644
index 0000000..57e6fac
--- /dev/null
+++ b/examples/09-operator-identity/main.py
@@ -0,0 +1,29 @@
+"""Example: optional verified operator identity on session open."""
+
+from aura import agent, configure
+from aura.identity.adapters.mock import MockIdentityAdapter
+
+
+def main() -> None:
+ configure()
+
+ # Enterprise path: pass a verified adapter (mock stands in for Auth0/OIDC in CI).
+ adapter = MockIdentityAdapter(subject="operator@example.com", verified=True)
+
+ ag = agent(
+ "identity-demo",
+ agent_ref="demo/identity",
+ ids={"external": {"ticket": "INC-1001"}},
+ )
+
+ with ag.session(identity_adapter=adapter, export=False) as run:
+ run.emit("turn.start", {"note": "session with operator trailer"})
+ run.emit("turn.end", {"tokens": 1})
+
+ print("session_id:", run.session_id)
+ print("operator:", run.summary["identity"])
+ print("agent_ids:", run.summary["agent_ids"]["ids"])
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/README.md b/examples/README.md
index f841a30..c71490c 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -27,6 +27,7 @@ Set `AURA_HOME` to isolate storage during tests or demos.
| [06-skillware-sequencer-chain](06-skillware-sequencer-chain/) | Sequencer chain with conditional `when` steps |
| [07-observer-presets](07-observer-presets/) | Monitor + Break observer presets on ToolHost |
| [08-emit-only-loop](08-emit-only-loop/) | Loose coat — emit-only, no tool host |
+| [09-operator-identity](09-operator-identity/) | Optional verified operator trailer (mock adapter) |
```bash
python examples/07-observer-presets/main.py
diff --git a/integrations/identity/README.md b/integrations/identity/README.md
new file mode 100644
index 0000000..47156d5
--- /dev/null
+++ b/integrations/identity/README.md
@@ -0,0 +1,120 @@
+# Operator identity
+
+Optional verified operator trailer for enterprise session receipts ([#55](https://github.com/ARPAHLS/aura/issues/55)).
+
+## Default (unchanged)
+
+No identity adapter → lite `agent_ref` + `aura_id` only. No login, no extra fields.
+
+## Enable mock (CI / local)
+
+```bash
+export AURA_MOCK_OPERATOR_SUBJECT=ci-operator@corp.com
+```
+
+```yaml
+# ~/.aura/config.yaml or aura.project.yaml
+identity:
+ adapter: mock
+ subject: ci-operator@corp.com
+identity_export_pii: false
+```
+
+## OIDC / Auth0
+
+```yaml
+identity:
+ adapter: auth0
+ domain: your-tenant.auth0.com
+ audience: your-api-audience
+```
+
+```bash
+export AURA_AUTH0_TOKEN=""
+# or AURA_OIDC_TOKEN
+pip install "aura-harness[identity]"
+```
+
+Signature verification uses JWKS (`pyjwt` + `cryptography`). **Do not use `verify_signature: false` in production** — that mode is for local tests only.
+
+## SDK
+
+```python
+from aura import agent, configure
+from aura.identity.adapters.mock import MockIdentityAdapter
+
+configure()
+ag = agent("bot")
+adapter = MockIdentityAdapter(subject="ops@corp.com")
+with ag.session(identity_adapter=adapter) as run:
+ run.emit("turn.start", {})
+print(run.summary["identity"])
+```
+
+Manual (unverified) operator on profile:
+
+```yaml
+ids:
+ operator:
+ subject: ops@corp.com
+ verified: false
+ method: manual
+```
+
+## Bring your own adapter
+
+Implement the protocol and pass it to `session()` — no central AURA identity service required:
+
+```python
+from aura.identity.models import OperatorIdentity
+from aura.identity.protocol import IdentityContext
+
+
+class CorpSsoAdapter:
+ method = "corp_sso"
+
+ def resolve(self, context: IdentityContext) -> OperatorIdentity | None:
+ token = context.env.get("CORP_SSO_TOKEN")
+ if not token:
+ return None
+ # validate with your IdP here
+ return OperatorIdentity(
+ verified=True,
+ method=self.method,
+ subject="user-123",
+ session_ref=context.session_id,
+ )
+
+
+with ag.session(identity_adapter=CorpSsoAdapter()) as run:
+ run.emit("turn.start", {})
+```
+
+For opaque third-party ids without verification, nest under `ids.external` on the agent profile — no adapter needed.
+
+Profile `types` entry (built-in adapter names):
+
+```yaml
+types:
+ - role: identity
+ type_id: arpa.identity.oidc
+ config:
+ adapter: oidc
+ issuer: https://login.example.com/
+ audience: aura-api
+```
+
+## Spine and export
+
+Successful bind emits `identity.bound`. Operator appears under `agent_ids.ids.operator` on **every event** for SIEM parity.
+
+By default, `email` / `name` / `phone` are stripped from **summary and OTel** export (JSONL spine keeps full fields for forensic review):
+
+```yaml
+identity_export_pii: true # opt in to include PII on export surfaces
+identity_required: true # fail session open when no operator resolves
+```
+
+CLI: `aura identity show`
+
+→ [trust-paths.md](../docs/trust-paths.md) · [examples/09-operator-identity](../examples/09-operator-identity/main.py)
diff --git a/pyproject.toml b/pyproject.toml
index 0c6e3dc..4092987 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -69,6 +69,7 @@ openai = ["openai>=1.0"]
anthropic = ["anthropic>=0.40"]
google = ["google-generativeai>=0.8"]
ollama = ["ollama>=0.4.0"]
+identity = ["pyjwt>=2.8", "cryptography>=41"]
[project.scripts]
aura = "aura.cli.main:main"
diff --git a/spec/aura-event.schema.json b/spec/aura-event.schema.json
index 2fb57ae..2426b02 100644
--- a/spec/aura-event.schema.json
+++ b/spec/aura-event.schema.json
@@ -14,9 +14,19 @@
"step_id": { "type": "string" },
"task_id": { "type": "string" },
"timestamp": { "type": "string", "format": "date-time" },
- "kind": { "type": "string" },
+ "kind": {
+ "type": "string",
+ "description": "Event type, e.g. session.open, session.close, identity.bound, membrane.ingress, tool.call, constraint.approved"
+ },
"constitution_hash": { "type": "string" },
- "payload": { "type": "object" },
+ "payload": {
+ "type": "object",
+ "description": "Event-specific body. identity.bound carries operator verification metadata under operator."
+ },
+ "agent_ids": {
+ "type": "object",
+ "description": "Identity trailer copied on each event (agent_ref, aura_id, policy_version, ids.operator when bound)."
+ },
"telemetry": {
"type": "object",
"properties": {
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 563fe3d..32466d4 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -215,6 +215,15 @@ def test_cli_config_show_and_paths(run_aura, aura_home: Path, project_dir: Path)
assert "storage=global saved" in set_storage.stdout
+def test_cli_identity_show(run_aura):
+ result = run_aura("identity", "show")
+ assert result.returncode == 0
+ data = json.loads(result.stdout)
+ assert "identity" in data
+ assert "identity_required" in data
+ assert "env_hints" in data
+
+
def test_aura_console_script_entry_point():
import importlib.metadata
diff --git a/tests/test_examples_smoke.py b/tests/test_examples_smoke.py
index f04f5cb..7a95657 100644
--- a/tests/test_examples_smoke.py
+++ b/tests/test_examples_smoke.py
@@ -13,7 +13,17 @@
"main_py",
sorted((Path(__file__).resolve().parents[1] / "examples").glob("*.py")),
)
-def test_example_runs(main_py: Path, aura_home):
+def test_example_runs(main_py: Path, aura_home: Path):
+ result = run_example(main_py, aura_home)
+ assert result.returncode == 0, result.stderr or result.stdout
+ assert "session" in result.stdout.lower()
+
+
+@pytest.mark.parametrize(
+ "main_py",
+ sorted((Path(__file__).resolve().parents[1] / "examples").glob("*/main.py")),
+)
+def test_example_folder_runs(main_py: Path, aura_home: Path):
result = run_example(main_py, aura_home)
assert result.returncode == 0, result.stderr or result.stdout
assert "session" in result.stdout.lower()
diff --git a/tests/test_identity.py b/tests/test_identity.py
new file mode 100644
index 0000000..b112652
--- /dev/null
+++ b/tests/test_identity.py
@@ -0,0 +1,195 @@
+"""Operator identity adapter tests."""
+
+from __future__ import annotations
+
+import base64
+import json
+from pathlib import Path
+
+import pytest
+
+from aura import agent, configure
+from aura.exporters.jsonl import build_session_summary
+from aura.exporters.otel import events_to_spans
+from aura.identity.adapters.mock import MockIdentityAdapter
+from aura.identity.errors import IdentityRequiredError
+from aura.identity.redaction import redact_summary
+
+
+def _jwt(payload: dict) -> str:
+ header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).decode().rstrip("=")
+ body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
+ return f"{header}.{body}.sig"
+
+
+def test_no_identity_unchanged(aura_home: Path):
+ ag = agent("id-none")
+ with ag.session(export=False) as run:
+ run.emit("turn.start", {})
+ assert run.summary is not None
+ assert run.summary.get("identity") is None
+ kinds = [e.kind for e in run._session.spine.stream()]
+ assert "identity.bound" not in kinds
+
+
+def test_mock_adapter_binds_and_emits(aura_home: Path):
+ configure(identity={"adapter": "mock", "subject": "ci-operator"})
+ ag = agent("id-mock")
+ with ag.session(export=False) as run:
+ run.emit("turn.start", {})
+ assert run.summary["identity"]["subject"] == "ci-operator"
+ assert run.summary["identity"]["verified"] is True
+ kinds = [e.kind for e in run._session.spine.stream()]
+ assert "identity.bound" in kinds
+ trailer = run._session.agent_ids_trailer()
+ assert trailer["ids"]["operator"]["subject"] == "ci-operator"
+
+
+def test_manual_operator_from_profile(aura_home: Path):
+ ag = agent(
+ "id-manual",
+ ids={"operator": {"subject": "ops@corp.com", "verified": False, "method": "manual"}},
+ )
+ with ag.session(export=False) as run:
+ run.emit("turn.start", {})
+ assert run.summary["identity"]["subject"] == "ops@corp.com"
+ assert run.summary["identity"]["verified"] is False
+
+
+def test_session_operator_override(aura_home: Path):
+ ag = agent("id-override")
+ with ag.session(
+ export=False,
+ operator={"subject": "session-operator", "verified": True, "method": "manual"},
+ ) as run:
+ run.emit("turn.start", {})
+ assert run.summary["identity"]["subject"] == "session-operator"
+
+
+def test_identity_required_raises(aura_home: Path):
+ configure(identity_required=True)
+ ag = agent("id-required")
+ with pytest.raises(IdentityRequiredError):
+ with ag.session(export=False) as run:
+ run.emit("turn.start", {})
+ configure(identity_required=False)
+
+
+def test_programmatic_adapter(aura_home: Path):
+ ag = agent("id-adapter")
+ adapter = MockIdentityAdapter(subject="sdk-operator", email="sdk@example.com")
+ with ag.session(export=False, identity_adapter=adapter) as run:
+ run.emit("turn.start", {})
+ assert run.summary["identity"]["subject"] == "sdk-operator"
+
+
+def test_oidc_unverified_decode(aura_home: Path):
+ token = _jwt({"sub": "oidc-user-1", "email": "oidc@corp.com", "name": "OIDC User"})
+ configure(identity={"adapter": "oidc", "token": token, "verify_signature": False})
+ ag = agent("id-oidc")
+ with ag.session(export=False) as run:
+ run.emit("turn.start", {})
+ assert run.summary["identity"]["subject"] == "oidc-user-1"
+ assert run.summary["identity"]["method"] == "oidc"
+
+
+def test_export_redacts_email_by_default(aura_home: Path):
+ configure(identity={"adapter": "mock", "subject": "redact-me", "email": "secret@corp.com"})
+ ag = agent("id-redact")
+ with ag.session() as run:
+ run.emit("turn.start", {})
+ summary = json.loads(Path(run.exports["summary"]).read_text(encoding="utf-8"))
+ operator = summary["agent_ids"]["ids"]["operator"]
+ assert operator["subject"] == "redact-me"
+ assert operator.get("email") is None
+ assert operator.get("subject_hash")
+
+
+def test_export_pii_includes_email(aura_home: Path):
+ configure(
+ identity={"adapter": "mock", "subject": "pii-me", "email": "visible@corp.com"},
+ identity_export_pii=True,
+ )
+ ag = agent("id-pii")
+ with ag.session() as run:
+ run.emit("turn.start", {})
+ summary = json.loads(Path(run.exports["summary"]).read_text(encoding="utf-8"))
+ assert summary["agent_ids"]["ids"]["operator"]["email"] == "visible@corp.com"
+
+
+def test_agent_ids_on_every_event(aura_home: Path):
+ configure(identity={"adapter": "mock", "subject": "trail-operator"})
+ ag = agent("id-trailer")
+ with ag.session(export=False) as run:
+ run.emit("turn.start", {})
+ for event in run._session.spine.stream():
+ if event.kind in {"membrane.ingress", "session.open", "identity.bound", "turn.start"}:
+ assert event.agent_ids["ids"]["operator"]["subject"] == "trail-operator"
+
+
+def test_otel_promotes_operator_attributes(aura_home: Path):
+ configure(identity={"adapter": "mock", "subject": "otel-operator"})
+ ag = agent("id-otel")
+ with ag.session(export=False) as run:
+ run.emit("turn.start", {})
+ events = [e.to_dict() for e in run._session.spine.stream()]
+ spans = events_to_spans(events)
+ attrs = spans[-1]["attributes"]
+ assert attrs["aura.operator.subject"] == "otel-operator"
+ assert attrs["aura.operator.method"] == "mock"
+
+
+def test_approve_defaults_principal_to_operator(aura_home: Path):
+ from aura.core.constraints import ApprovalRequired
+
+ configure(identity={"adapter": "mock", "subject": "approver-operator"})
+ ag = agent("id-approve", rules=[{"type": "confirm_before", "tools": ["send"]}])
+ with ag.session(export=False) as run:
+ with pytest.raises(ApprovalRequired) as exc:
+ run.emit("tool.call", {"tool": "send"})
+ run.approve(exc.value.request_id)
+ approved = [e for e in run._session.spine.stream() if e.kind == "constraint.approved"]
+ assert approved[-1].payload["principal"] == "approver-operator"
+
+
+def test_profile_types_identity_config(aura_home: Path):
+ ag = agent(
+ "id-types",
+ types=[
+ {
+ "role": "identity",
+ "type_id": "arpa.identity.mock",
+ "config": {"adapter": "mock", "subject": "types-operator"},
+ }
+ ],
+ )
+ with ag.session(export=False) as run:
+ run.emit("turn.start", {})
+ assert run.summary["identity"]["subject"] == "types-operator"
+
+
+def test_redact_summary_helper():
+ summary = {
+ "agent_ids": {
+ "ids": {
+ "operator": {
+ "subject": "x",
+ "email": "hide@corp.com",
+ "subject_hash": "abc",
+ }
+ }
+ }
+ }
+ redacted = redact_summary(summary)
+ assert redacted["agent_ids"]["ids"]["operator"]["email"] is None
+
+
+def test_build_session_summary_identity_field(aura_home: Path):
+ ag = agent("id-summary")
+ with ag.session(
+ export=False,
+ operator={"subject": "inline", "verified": False},
+ ) as run:
+ run.emit("turn.start", {})
+ raw = build_session_summary(run._session)
+ assert raw["identity"]["subject"] == "inline"