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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **`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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ with ag.session() as run:
print(run.exports)
```

CLI: `aura agent create`, `aura run`, `aura export`, `aura compare`, `aura export-otel`.
CLI: `aura agent create`, `aura run`, `aura export`, `aura compare`, `aura export-otel`, `aura verify chain`.

→ [getting-started.md](docs/getting-started.md) · [examples/](examples/)

Expand Down
28 changes: 27 additions & 1 deletion aura/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from aura import __version__, agent, create_agent
from aura.agents.registry import AgentNotFoundError, AgentRegistry, DuplicateAgentError
from aura.core.compare import compare_sessions
from aura.core.spine import AuditSpine
from aura.core.spine import AuditSpine, first_broken_event_id, verify_hash_chain
from aura.exporters.otel import export_session_otel
from aura.runtime.python import run_script

Expand Down Expand Up @@ -217,6 +217,32 @@ def cmd_compare(session_a: str, session_b: str, *, console: Console | None = Non
return 0


def cmd_verify_chain(path: str, *, console: Console | None = None) -> int:
log_path = Path(path)
if not log_path.is_file():
message = f"not found: {log_path}"
if console is None:
print(message, file=sys.stderr)
else:
console.print(message, style="bold #FF9AA2")
return 1

spine = AuditSpine.from_jsonl(log_path)
valid = verify_hash_chain(spine) is True
result: dict[str, object] = {"hash_chain_valid": valid}
if not valid:
event_id = first_broken_event_id(spine)
if event_id is not None:
result["event_id"] = event_id

payload = json.dumps(result)
if console is None:
print(payload)
else:
console.print(payload, style="dim")
return 0 if valid else 1


def cmd_home(*, console: Console | None = None) -> int:
"""Show resolved paths (alias for paths view)."""
return cmd_paths(console=console)
Expand Down
6 changes: 4 additions & 2 deletions aura/cli/help_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
("aura export <session_id>", "print session summary JSON"),
("aura export-otel <session_id>", "write OTel-style JSONL beside session"),
("aura compare <a> <b>", "diff two session summaries"),
("aura verify chain <path>", "validate an exported JSONL hash chain"),
],
_DOCS_CLI,
),
Expand Down Expand Up @@ -55,7 +56,7 @@

_HELP_MENU: List[Tuple[str, str, str, Union[int, str]]] = [
("1", "agents", "create, list, show, set", 0),
("2", "sessions", "run, logs, export, compare", 1),
("2", "sessions", "run, logs, export, compare, verify", 1),
("3", "paths", "AURA_HOME and project storage", 2),
("4", "general", "menu, help, version", 3),
("5", "install", "pip install aura-harness", "install"),
Expand All @@ -73,11 +74,12 @@
"aura export aura_sess_01H...",
"aura compare sess_a sess_b",
"aura export-otel aura_sess_01H...",
"aura verify chain path/to/session.jsonl",
)

MAIN_MENU: List[Tuple[str, str, str]] = [
("1", "agents", "list, show, create, or edit profiles"),
("2", "sessions", "logs, export, compare, or export-otel"),
("2", "sessions", "logs, export, compare, verify, or export-otel"),
("3", "run", "run a Python script under an agent session"),
("4", "paths", "view/edit AURA_HOME, project, and config"),
("5", "help", "grouped CLI reference and doc links"),
Expand Down
7 changes: 7 additions & 0 deletions aura/cli/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,16 @@ def _sessions_submenu(
"otel": "export-otel",
"4": "compare",
"compare": "compare",
"5": "verify",
"verify": "verify",
}
while True:
console.print(Text("Sessions", style=f"bold {TABLE_STYLE}"))
console.print(" [1] logs — print session JSONL", style=MENU_STYLE)
console.print(" [2] export — session summary JSON", style=MENU_STYLE)
console.print(" [3] export-otel — OTel-style JSONL export", style=MENU_STYLE)
console.print(" [4] compare — diff two session summaries", style=MENU_STYLE)
console.print(" [5] verify — validate an exported hash chain", style=MENU_STYLE)
_print_nav_footer(console, show_back=True)

raw = _read_line(" sessions> ", input_fn)
Expand Down Expand Up @@ -176,6 +179,10 @@ def _sessions_submenu(
session_b = _read_line(" session_b> ", input_fn)
if session_a and session_b and session_a.strip() and session_b.strip():
commands.cmd_compare(session_a.strip(), session_b.strip(), console=console)
elif command == "verify":
path = _read_line(" JSONL path> ", input_fn)
if path and path.strip():
commands.cmd_verify_chain(path.strip(), console=console)
else:
console.print(f" Unknown choice: '{choice}'", style="dim #FF9AA2")
console.print()
Expand Down
10 changes: 10 additions & 0 deletions aura/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ 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")

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")
chain_p.add_argument("path", help="Path to an exported session JSONL file")

return parser


Expand Down Expand Up @@ -144,6 +149,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 == "verify":
if args.verify_command == "chain":
return commands.cmd_verify_chain(args.path)
print("usage: aura verify chain <path>", file=sys.stderr)
return 1
if args.command is None:
if args.help:
cmd_help()
Expand Down
12 changes: 12 additions & 0 deletions aura/core/spine.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,18 @@ def verify_hash_chain(spine: AuditSpine) -> bool | None:
return True if saw_hash else None


def first_broken_event_id(spine: AuditSpine) -> str | None:
"""Return the first event_id whose content hash does not match the chain."""
prev: str | None = None
for event in spine.stream():
if event.content_hash is None:
continue
if compute_content_hash(event, prev) != event.content_hash:
return event.event_id
prev = event.content_hash
return None


def verify_hash_chain_dicts(events: list[dict[str, Any]]) -> bool | None:
prev: str | None = None
saw_hash = False
Expand Down
1 change: 1 addition & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ aura agent list
aura run my-bot path/to/script.py
aura logs aura_sess_xxxxxxxxxxxx
aura export aura_sess_xxxxxxxxxxxx
aura verify chain ~/.aura/sessions/aura_sess_xxxxxxxxxxxx.jsonl
```

## Agent profile (optional YAML)
Expand Down
4 changes: 3 additions & 1 deletion docs/outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ What a session produces on close (v0.3).
| **Summary** | `{session_id}.summary.json` | Metadata, conformance, audit report |
| **OTel JSONL** | `{session_id}.otel.jsonl` | Span-style records mapped from events |

CLI: `aura export`, `aura export-otel`, `aura compare`.
CLI: `aura export`, `aura export-otel`, `aura compare`, `aura verify chain <path>`.

---

Expand Down Expand Up @@ -42,6 +42,8 @@ Binary pass/fail plus violations list — declared rules and sequencer step orde

Each event includes `prev_hash` and `content_hash` (SHA-256). Tampering or corruption breaks verification in the audit report.

Use `aura verify chain <path>` to validate an exported JSONL audit trail directly. It prints a JSON object with `hash_chain_valid`; when the chain is broken, the object also identifies the first affected `event_id` and the command exits with status 1.

---

## Identity on exports
Expand Down
22 changes: 22 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path

from aura import agent
from aura.core.spine import AuditSpine


def test_cli_version(run_aura):
Expand Down Expand Up @@ -91,6 +92,26 @@ def test_cli_logs_export_compare_otel(run_aura, aura_home: Path):
assert diff["event_count"]["b"] < diff["event_count"]["a"]


def test_cli_verify_chain(run_aura, tmp_path: Path):
path = tmp_path / "session.jsonl"
spine = AuditSpine("session", "aura-id", path)
spine.append("turn.start", {"input": "hello"})
spine.append("turn.end", {"output": "world"})

valid = run_aura("verify", "chain", str(path))
assert valid.returncode == 0
assert json.loads(valid.stdout) == {"hash_chain_valid": True}

rows = AuditSpine.read_jsonl(path)
rows[1]["content_hash"] = "0" * 64
path.write_text("\n".join(json.dumps(row) for row in rows) + "\n", encoding="utf-8")
broken = run_aura("verify", "chain", str(path))
assert broken.returncode == 1
payload = json.loads(broken.stdout)
assert payload["hash_chain_valid"] is False
assert payload["event_id"] == rows[1]["event_id"]


def test_cli_run_requires_script(run_aura):
result = run_aura("run", "agent-only")
assert result.returncode == 1
Expand All @@ -102,6 +123,7 @@ def test_cli_help_grouped(run_aura):
assert result.returncode == 0
assert "agents" in result.stdout.lower()
assert "aura agent list" in result.stdout
assert "aura verify chain path/to/session.jsonl" in result.stdout
assert "interactive" in result.stdout.lower()


Expand Down