HDK-audit / src /hdk /cli.py
JakCwi's picture
Initial release: code, docs, tests (CC BY-NC 4.0)
977ccde
Raw History Blame Contribute Delete
12.3 kB
# 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()