Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Break observer preset** — `observer.alert` on repeated tool intents ([#34](https://github.com/ARPAHLS/aura/issues/34)).
- **Sequencer `when`** — conditional step skip with `sequencer.step.skipped` on the spine.
- **Ingress bind enrichment** — `host.bind`, `bound_skill_ids`, `session_snapshot_hash` on `skill.registered` ([#33](https://github.com/ARPAHLS/aura/issues/33)).
- **OTel promoted attributes** — `aura.agent_ref`, `aura.policy_version`, `aura.principal`, `aura.skill_id` on spans ([#35](https://github.com/ARPAHLS/aura/issues/35)).
- **Capstone guide** — [docs/guides/reference-tool-host-capstone.md](docs/guides/reference-tool-host-capstone.md) ([#40](https://github.com/ARPAHLS/aura/issues/40)).
- **Examples 07–08** — observer presets demo, emit-only loose coat ([#41](https://github.com/ARPAHLS/aura/issues/41)).
- **`aura verify chain <path>`** — validate an exported JSONL hash chain for CI and archive checks, reporting the first broken `event_id`.
- **Python 3.13** package classifier — matches the CI matrix and `requires-python = ">=3.10"` ([GH #10](https://github.com/ARPAHLS/aura/issues/10)).
- **Core test coverage (GH #4)** — config layers, legacy + ULID coexistence, tampered JSONL → audit report `HASH_CHAIN_BROKEN`, constraint allow/deny/token matrix, session mode + project storage paths, compare `agent_ref` / `hash_chain_valid` diffs.
- **`AuditSpine.from_jsonl()`** — reload spine from disk for verify/tamper checks.
- **Compare sessions** — `agent_ref.same` and `hash_chain_valid` fields in diff output.

- **`aura agent set`** — update `agent_ref`, purpose, skills, variables, ids, and rules on existing profiles.
- **`aura config show`** — merged global/project config and resolved registry/sessions paths.
- **`aura paths`** — view paths; **`set-project`** and **`set-storage`** persist settings to YAML.
Expand All @@ -33,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Example 06** — compress step skips when scan `is_safe` is false (sequencer `when`).
- **PR CI** — `lint-test` now covers Python 3.10–3.13 on Ubuntu (`fail-fast`); publish remains a 3.12 release gate ([GH #10](https://github.com/ARPAHLS/aura/issues/10)).
- **`AgentRegistry.update_profile`** — registry ref/alias maps stay consistent when `agent_ref` changes.
- **Global config** — optional persisted `project_dir` in `~/.aura/config.yaml`.
Expand Down
5 changes: 5 additions & 0 deletions aura/core/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ def _attach_profile_observers(self) -> None:

self._observers.append(create_monitor_observer(self, entry))
continue
if preset == "break":
from aura.observers.presets.break_observer import create_break_observer

self._observers.append(create_break_observer(self, entry))
continue
obs_id = entry.get("id")
if not obs_id:
continue
Expand Down
55 changes: 48 additions & 7 deletions aura/exporters/otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,64 @@
from aura.core.spine import AuditSpine


def _promoted_attributes(event: dict[str, Any]) -> dict[str, Any]:
"""First-class identity fields for SIEM parity with the JSONL spine."""
attrs: dict[str, Any] = {}
agent_ids = event.get("agent_ids") or {}
payload = event.get("payload") or {}
context = payload.get("context") if isinstance(payload.get("context"), dict) else {}

policy_version = (
payload.get("policy_version")
or agent_ids.get("policy_version")
or context.get("policy_version")
)
if policy_version is not None:
attrs["aura.policy_version"] = str(policy_version)

agent_ref = payload.get("agent_ref") or context.get("agent_ref")
if agent_ref:
attrs["aura.agent_ref"] = str(agent_ref)

principal = payload.get("principal")
if principal:
attrs["aura.principal"] = str(principal)

skill_id = payload.get("skill_id")
if skill_id:
attrs["aura.skill_id"] = str(skill_id)

manifest_hash = payload.get("manifest_snapshot_hash")
if manifest_hash:
attrs["aura.manifest_snapshot_hash"] = str(manifest_hash)

step_id = event.get("step_id") or payload.get("step_id")
if step_id:
attrs["aura.step_id"] = str(step_id)

return attrs


def events_to_spans(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
spans: list[dict[str, Any]] = []
for event in events:
promoted = _promoted_attributes(event)
attributes = {
"aura.session_id": event.get("session_id"),
"aura.aura_id": event.get("aura_id"),
"aura.step_id": event.get("step_id"),
"aura.agent_ids": json.dumps(event.get("agent_ids") or {}),
"aura.payload": json.dumps(event.get("payload") or {}),
**promoted,
}
spans.append(
{
"trace_id": event.get("trace_id"),
"span_id": event.get("event_id"),
"parent_span_id": event.get("parent_id"),
"name": event.get("kind"),
"start_time_unix_nano": None,
"attributes": {
"aura.session_id": event.get("session_id"),
"aura.aura_id": event.get("aura_id"),
"aura.step_id": event.get("step_id"),
"aura.agent_ids": json.dumps(event.get("agent_ids") or {}),
"aura.payload": json.dumps(event.get("payload") or {}),
},
"attributes": attributes,
"status": {"code": "OK"},
}
)
Expand Down
46 changes: 46 additions & 0 deletions aura/hosts/bind.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Host bind helpers — ingress events when capabilities register on a session."""

from __future__ import annotations

from typing import Any

from aura.membrane.ingress import skill_registered_payload


def record_skill_bind(
session: Any,
*,
skill_id: str,
manifest_snapshot_hash: str,
host_kind: str = "toolhost",
) -> None:
"""Emit skill.registered and optional first-time host.bind on the audit spine."""
host_state = session.state.setdefault("host", {"kind": host_kind, "bound_skills": []})
bound: list[str] = host_state.setdefault("bound_skills", [])
first_bind = len(bound) == 0
if skill_id not in bound:
bound.append(skill_id)

session.emit(
"skill.registered",
skill_registered_payload(
session.profile,
skill_id=skill_id,
manifest_snapshot_hash=manifest_snapshot_hash,
rule_count=len(session.rules),
session_snapshot_hash=session.snapshot_hash,
bound_skill_ids=list(bound),
),
)

if first_bind:
session.emit(
"host.bind",
{
"membrane": "ingress",
"host_kind": host_kind,
"session_snapshot_hash": session.snapshot_hash,
"agent_ref": session.profile.agent_ref,
"policy_version": session.profile.policy_version,
},
)
15 changes: 6 additions & 9 deletions aura/hosts/skillware.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

from typing import Any

from aura.hosts.bind import record_skill_bind
from aura.hosts.manifest import manifest_snapshot_hash, merge_manifest_into_rules
from aura.hosts.protocol import SkillExecutor
from aura.hosts.skillware_adapter import SkillwareRegistrySkill, load_registry_skill
from aura.membrane.egress import guarded_tool_call
from aura.membrane.ingress import skill_registered_payload


class SkillwareHost:
Expand Down Expand Up @@ -100,14 +100,11 @@ def _bind_manifest(self, skill_id: str, manifest: dict[str, Any]) -> None:
session.rules = merge_manifest_into_rules(session.rules, skill_id, manifest)
snapshot = manifest_snapshot_hash(skill_id, manifest)
session.snapshot_hash = _recompute_snapshot_hash(session)
session.emit(
"skill.registered",
skill_registered_payload(
session.profile,
skill_id=skill_id,
manifest_snapshot_hash=snapshot,
rule_count=len(session.rules),
),
record_skill_bind(
session,
skill_id=skill_id,
manifest_snapshot_hash=snapshot,
host_kind="skillware",
)


Expand Down
4 changes: 4 additions & 0 deletions aura/membrane/ingress.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,17 @@ def skill_registered_payload(
skill_id: str,
manifest_snapshot_hash: str,
rule_count: int,
session_snapshot_hash: str | None = None,
bound_skill_ids: list[str] | None = None,
) -> dict[str, Any]:
"""Normalized bind-time context when a host registers a skill."""
return {
"membrane": "ingress",
"bind": "skill",
"skill_id": skill_id,
"manifest_snapshot_hash": manifest_snapshot_hash,
"session_snapshot_hash": session_snapshot_hash,
"bound_skill_ids": list(bound_skill_ids or []),
"policy_version": profile.policy_version,
"agent_ref": profile.agent_ref,
"constitution_rule_count": rule_count,
Expand Down
8 changes: 7 additions & 1 deletion aura/observers/presets/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
"""Packaged observer presets (monitor, break, …)."""

from aura.observers.presets.break_observer import BreakObserver, create_break_observer
from aura.observers.presets.monitor import MonitorObserver, create_monitor_observer

__all__ = ["MonitorObserver", "create_monitor_observer"]
__all__ = [
"BreakObserver",
"MonitorObserver",
"create_break_observer",
"create_monitor_observer",
]
77 changes: 77 additions & 0 deletions aura/observers/presets/break_observer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Break observer preset — circuit-breaker alerts for runaway tool patterns."""

from __future__ import annotations

import json
from typing import Any, TYPE_CHECKING

if TYPE_CHECKING:
from aura.core.session import Session


class BreakObserver:
"""
Detect repeated identical tool intents and emit observer.alert on the spine.
Does not block egress — host or escalation layer acts on alerts.
"""

def __init__(
self, observer_id: str, session: Session, config: dict[str, Any] | None = None
) -> None:
self.observer_id = observer_id
self._session = session
self._config = dict(config or {})
self._intent_signatures: dict[str, int] = {}
self._alerts: list[dict[str, Any]] = []

def on_event(self, event: dict[str, Any]) -> None:
if event.get("kind") != "tool.intent":
return
payload = dict(event.get("payload") or {})
tool = str(payload.get("tool") or "unknown")
args_key = json.dumps(payload.get("args") or {}, sort_keys=True)
sig = f"{tool}:{args_key}"
self._intent_signatures[sig] = self._intent_signatures.get(sig, 0) + 1
threshold = int(self._config.get("max_identical_intents", 5))
count = self._intent_signatures[sig]
if threshold > 0 and count >= threshold:
self._emit_alert(
"repeated_tool_intent",
{
"tool": tool,
"count": count,
"threshold": threshold,
"signature": sig,
},
)

def summary(self) -> dict[str, Any]:
return {
"intent_signatures": dict(self._intent_signatures),
"alerts_emitted": len(self._alerts),
}

def _emit_alert(self, alert_type: str, detail: dict[str, Any]) -> None:
last = self._alerts[-1] if self._alerts else None
if (
last
and last.get("type") == alert_type
and last.get("signature") == detail.get("signature")
):
if last.get("count") == detail.get("count"):
return
alert = {"type": alert_type, **detail, "observer_id": self.observer_id}
self._alerts.append(alert)
spine = self._session.spine
if spine is not None:
spine.append(
"observer.alert",
alert,
agent_ids=self._session.profile.id_trailer(),
)


def create_break_observer(session: Session, entry: dict[str, Any]) -> BreakObserver:
obs_id = str(entry.get("id") or "break")
config = entry.get("config") if isinstance(entry.get("config"), dict) else {}
return BreakObserver(obs_id, session, config)
40 changes: 39 additions & 1 deletion aura/sequencer/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,51 @@ def run(self, spec: dict[str, Any] | None = None) -> dict[str, Any]:
completed: list[str] = []
for step in steps:
self._validate_dependencies(step, completed)
result = self._run_step(step)
skip_reason = self._skip_reason(step)
if skip_reason:
result = self._skip_step(step, skip_reason)
else:
result = self._run_step(step)
self.session.state.setdefault("sequencer", {})[step.id] = result
completed.append(step.id)

self.session.emit("sequencer.complete", {"steps": completed})
return {"completed": completed, "steps": len(completed)}

def _skip_reason(self, step: SequencerStep) -> str | None:
when = step.when
if not when:
return None
prior = when.get("prior_step")
field = when.get("field")
if not prior or not field:
return None
state = self.session.state.get("sequencer") or {}
prior_result = state.get(prior)
if not isinstance(prior_result, dict):
return f"prior step {prior!r} has no result"
actual = prior_result.get(field)
if "equals" in when and actual != when.get("equals"):
return f"{field}={actual!r} expected {when.get('equals')!r}"
if when.get("truthy") and not actual:
return f"{field} is falsy"
return None

def _skip_step(self, step: SequencerStep, reason: str) -> dict[str, Any]:
self.session.emit(
"sequencer.step.start",
{"type": step.step_type, "ref": step.ref, "attempt": 0, "skipped": True},
step_id=step.id,
)
payload = {"status": "skipped", "reason": reason}
self.session.emit("sequencer.step.skipped", payload, step_id=step.id)
self.session.emit(
"sequencer.step.end",
{"status": "skipped", "reason": reason},
step_id=step.id,
)
return payload

def _validate_dependencies(self, step: SequencerStep, completed: list[str]) -> None:
missing = [d for d in step.depends_on if d not in completed]
if missing:
Expand Down
1 change: 1 addition & 0 deletions aura/sequencer/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def load_steps(spec: dict[str, Any] | None) -> list[SequencerStep]:
depends_on=list(s.get("depends_on") or []),
retry=dict(s.get("retry") or {}),
gates=list(s.get("gates") or []),
when=dict(s.get("when") or {}),
config=dict(s.get("config") or {}),
)
for s in raw
Expand Down
1 change: 1 addition & 0 deletions aura/sequencer/step.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ class SequencerStep:
depends_on: list[str] = field(default_factory=list)
retry: dict[str, Any] = field(default_factory=dict)
gates: list[str] = field(default_factory=list)
when: dict[str, Any] = field(default_factory=dict)
config: dict[str, Any] = field(default_factory=dict)
5 changes: 3 additions & 2 deletions docs/guides/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ Long-form integration and usage guides.

| Guide | Description |
|---|---|
| [aura-on-skillware.md](aura-on-skillware.md) | **Skillware + AURA** — stack, skill types, provider loops, sequencer chains, best practices |
| [skillware-follow-ups.md](skillware-follow-ups.md) | Suggested post-merge GitHub issues for CI, capstone, and demos |
| [aura-on-skillware.md](aura-on-skillware.md) | Reference adapter deep dive (Skillware as one ToolHost impl) |
| [reference-tool-host-capstone.md](reference-tool-host-capstone.md) | **360° tool-host checklist** — membrane before/at/after, spine events, runnable paths |
| [skillware-follow-ups.md](skillware-follow-ups.md) | Suggested follow-up GitHub issues |

See also: [using-aura.md](../using-aura.md), [skillware-integration.md](../skillware-integration.md).
Loading
Loading