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"