# SPDX-License-Identifier: CC-BY-NC-4.0 # Copyright (c) 2026 RE::AI Strategy """HDK command line interface (installed as `hdk-audit`).""" from __future__ import annotations import argparse import json import sys from pathlib import Path def _get_db_path(args) -> str: """Get DB path from args or default.""" if hasattr(args, "db") and args.db: return args.db default = Path.home() / ".hdk" / "dag.sqlite" return str(default) def _make_canary(dag, args): """Canary with the key from --canary-key-file, $HDK_CANARY_KEY or $HDK_CANARY_KEY_FILE.""" from hdk.core.canary import CanaryLayer key_file = getattr(args, "canary_key_file", None) return CanaryLayer(dag, key_file=key_file) def cmd_verify(args): """Full integrity verification.""" from hdk.core.dag import DAGStorage db_path = _get_db_path(args) if not Path(db_path).exists() and db_path != ":memory:": print(f"Database not found: {db_path}") sys.exit(2) dag = DAGStorage(db_path) canary = _make_canary(dag, args) dag.set_node_key(canary.node_key()) # 0.1.2: woven nodes' hashes are HMACs under the key dag_valid, dag_errors = dag.verify_integrity() node_count = dag.get_node_count() un_anchored = dag.get_nodes_since_anchor(-1) last_anchor = dag.get_last_anchor() # compares the stored counter with its commitment, the last anchored checkpoint and # the generations woven into nodes; None = disabled (no key) canary_valid, canary_reason = canary.check() # Output print("┌─────────────────────────────────────┐") print("│ HDK INTEGRITY VERIFICATION │") print("├─────────────────────────────────────┤") dag_status = "✓ VALID" if dag_valid else "✗ INVALID" print(f"│ DAG Integrity: {dag_status:<18}│") print(f"│ Node Count: {node_count:<18}│") canary_status = {True: "✓ GREEN", False: "✗ TAMPERED", None: "- DISABLED"}[canary_valid] print(f"│ Canary Status: {canary_status:<18}│") if last_anchor: print(f"│ Last Anchor: #{last_anchor.anchor_sequence:<17}│") print(f"│ Topic: {last_anchor.topic_id:<18}│") else: print(f"│ Last Anchor: {'None':<18}│") print(f"│ Un-anchored: {len(un_anchored):<18}│") print("└─────────────────────────────────────┘") if canary_valid is not True: print(f"\nCanary: {canary_reason}") if canary.node_key() is None: print("\nNo canary key: entries not yet anchored are protected by anchoring only " "(they can be rewritten with recomputed hashes).") if dag.unchecked_keyed: print(f"\n{dag.unchecked_keyed} keyed node(s) not checked (no key, or a rotated one).") if dag_errors: print("\nErrors:") for err in dag_errors: print(f" ✗ {err}") dag.close() sys.exit(0 if dag_valid and canary_valid is not False else 1) def cmd_inspect(args): """Show full details of a specific node.""" from hdk.core.dag import DAGStorage db_path = _get_db_path(args) dag = DAGStorage(db_path) node = dag.get_node_by_id(args.node_id) if not node: node = dag.get_node(args.node_id) if not node: print(f"Node not found: {args.node_id}") dag.close() sys.exit(2) print(f"Node ID: {node.id}") print(f"Level: {node.level.value}") print(f"Event Type: {node.event_type.value}") print(f"Timestamp: {node.timestamp.isoformat()}") print(f"Content Hash: {node.content_hash}") print(f"Self Hash: {node.self_hash}") print(f"Parent Chain: {node.parent_chain}") print(f"Canary Gen: {node.canary_generation}") print(f"Model: {node.model or 'N/A'}") print(f"Provider: {node.provider or 'N/A'}") print(f"Node Hash: {node.node_hash}") dag.close() def cmd_tree(args): """Show DAG hierarchy as tree.""" from hdk.core.dag import DAGStorage db_path = _get_db_path(args) dag = DAGStorage(db_path) depth = args.depth if hasattr(args, "depth") and args.depth else 3 cursor = dag._conn.execute("SELECT * FROM nodes ORDER BY timestamp ASC") nodes = [dag._row_to_node(row) for row in cursor.fetchall()] if not nodes: print("(empty DAG)") dag.close() return # Simple tree display: indent by level level_indent = { "PROJECT": 0, "TAB": 1, "CONV": 2, "QUERY": 3, "RESPONSE": 4, "EVENT": 2, } for node in nodes[:50]: # Limit display indent = level_indent.get(node.level.value, 2) prefix = " " * indent + "├── " model_info = f" [{node.model}]" if node.model else "" print(f"{prefix}{node.level.value} {node.id[:12]}...{model_info}") if len(nodes) > 50: print(f"\n ... and {len(nodes) - 50} more nodes") dag.close() def cmd_anchor(args): """Show anchor history or force anchor.""" from hdk.core.dag import DAGStorage db_path = _get_db_path(args) dag = DAGStorage(db_path) if hasattr(args, "now") and args.now: import asyncio from hdk.config import HederaConfig from hdk.hedera.anchor import HederaAnchorService # Hedera credentials from $HDK_HEDERA_OPERATOR_ID / _OPERATOR_KEY / _TOPIC_ID # (and _NETWORK); without them the anchor is recorded locally only. canary = _make_canary(dag, args) service = HederaAnchorService(HederaConfig.from_env(), dag, canary) async def _run(): try: return await service.submit_anchor(force=True) finally: await service.close() anchor = asyncio.run(_run()) if anchor: print(f"Anchor #{anchor.anchor_sequence} created: {anchor.merkle_root[:16]}...") print(f"Status: {anchor.status}") if anchor.sequence_number is not None: print(f"Hedera: topic {anchor.topic_id} sequence {anchor.sequence_number} " f"consensus {anchor.consensus_timestamp or 'unknown'}") else: print("No un-anchored nodes.") dag.close() return # Show anchor history anchors = dag.get_anchor_history(limit=20) if not anchors: print("No anchors yet.") dag.close() return for a in reversed(anchors): status = "✓ on Hedera" if a.sequence_number is not None else "local only" print(f" #{a.anchor_sequence} {a.timestamp.isoformat()[:19]} " f"nodes={a.node_count} {status} {a.topic_id}") dag.close() def cmd_export(args): """Export DAG as JSON.""" from hdk.core.dag import DAGStorage db_path = _get_db_path(args) dag = DAGStorage(db_path) print(dag.export_dag(format=args.format if hasattr(args, "format") else "json")) dag.close() def cmd_stats(args): """Show DAG statistics.""" from hdk.core.dag import DAGStorage db_path = _get_db_path(args) dag = DAGStorage(db_path) total = dag.get_node_count() # Count by type counts = {} for event_type in ["LLM_QUERY", "LLM_RESPONSE", "PASTE_EXTERNAL", "PUBLISH", "AI_INSERT", "SESSION_START", "SESSION_END", "CUSTOM"]: cursor = dag._conn.execute( "SELECT COUNT(*) FROM nodes WHERE event_type = ?", (event_type,) ) counts[event_type] = cursor.fetchone()[0] # Count by level level_counts = {} for level in ["PROJECT", "TAB", "CONV", "QUERY", "RESPONSE", "EVENT"]: cursor = dag._conn.execute( "SELECT COUNT(*) FROM nodes WHERE level = ?", (level,) ) level_counts[level] = cursor.fetchone()[0] anchor_cursor = dag._conn.execute("SELECT COUNT(*) FROM anchors") anchor_count = anchor_cursor.fetchone()[0] print(f"Total Nodes: {total}") print(f"Projects: {level_counts.get('PROJECT', 0)}") print(f"Conversations: {level_counts.get('CONV', 0)}") print(f"LLM Queries: {counts.get('LLM_QUERY', 0)}") print(f"LLM Responses: {counts.get('LLM_RESPONSE', 0)}") print(f"Pastes: {counts.get('PASTE_EXTERNAL', 0)}") print(f"Publishes: {counts.get('PUBLISH', 0)}") print(f"Anchors: {anchor_count}") # DB size db_file = Path(db_path) if db_file.exists(): size_kb = db_file.stat().st_size / 1024 print(f"DB Size: {size_kb:.1f} KB") dag.close() def cmd_certificate(args): """Generate certificate for a document.""" from hdk.certificate import CertificateGenerator from hdk.core.dag import DAGStorage from hdk.core.hash_engine import HashEngine db_path = _get_db_path(args) dag = DAGStorage(db_path) engine = HashEngine() doc_path = Path(args.document) if not doc_path.exists(): print(f"File not found: {args.document}") dag.close() sys.exit(2) content = doc_path.read_text(encoding="utf-8") from hdk.config import HederaConfig gen = CertificateGenerator(dag, engine, network=HederaConfig.from_env().network) cert = gen.generate(content, "cli-user") print(gen.to_markdown(cert)) dag.close() def main(): """Entry point for the `hdk-audit` CLI command.""" parser = argparse.ArgumentParser( prog="hdk-audit", description="HDK — Hedera Detection Key. Tamper-evident audit trail for LLM calls.", ) parser.add_argument("--version", action="store_true", help="Show version") subparsers = parser.add_subparsers(dest="command") # verify p_verify = subparsers.add_parser("verify", help="Verify DAG integrity + canary state") p_verify.add_argument("--db", help="Database path") p_verify.add_argument("--canary-key-file", help="Canary key file (else $HDK_CANARY_KEY[_FILE])") # inspect p_inspect = subparsers.add_parser("inspect", help="Show details of a specific node") p_inspect.add_argument("node_id", help="Node ID or hash") p_inspect.add_argument("--db", help="Database path") # tree p_tree = subparsers.add_parser("tree", help="Show DAG hierarchy") p_tree.add_argument("--depth", type=int, default=3, help="Tree depth") p_tree.add_argument("--db", help="Database path") # anchor p_anchor = subparsers.add_parser("anchor", help="Anchor management") p_anchor.add_argument("--status", action="store_true", help="Show anchor history") p_anchor.add_argument("--now", action="store_true", help="Force immediate anchor") p_anchor.add_argument("--db", help="Database path") p_anchor.add_argument("--canary-key-file", help="Canary key file (else $HDK_CANARY_KEY[_FILE])") # export p_export = subparsers.add_parser("export", help="Export DAG") p_export.add_argument("--format", choices=["json", "csv"], default="json") p_export.add_argument("--db", help="Database path") # certificate p_cert = subparsers.add_parser("certificate", help="Generate certificate") p_cert.add_argument("document", help="Document file path") p_cert.add_argument("--db", help="Database path") # stats p_stats = subparsers.add_parser("stats", help="Show DAG statistics") p_stats.add_argument("--db", help="Database path") # version subparsers.add_parser("version", help="Show version info") args = parser.parse_args() if args.version or args.command == "version": from hdk import __version__ print(__version__) return if args.command is None: parser.print_help() return commands = { "verify": cmd_verify, "inspect": cmd_inspect, "tree": cmd_tree, "anchor": cmd_anchor, "export": cmd_export, "certificate": cmd_certificate, "stats": cmd_stats, } handler = commands.get(args.command) if handler: handler(args) else: parser.print_help() sys.exit(2) if __name__ == "__main__": main()