From 27a609bfea0c3b9b768194ca8d0824cf140da923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:47:01 +0200 Subject: [PATCH 01/99] Integrate Habitat runtime: add habitat/__init__.py --- habitat/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 habitat/__init__.py diff --git a/habitat/__init__.py b/habitat/__init__.py new file mode 100644 index 0000000..5c38193 --- /dev/null +++ b/habitat/__init__.py @@ -0,0 +1 @@ +__version__='1.2.0' From c61521d67386e5da78c860fbe34fe27d7367de53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:47:04 +0200 Subject: [PATCH 02/99] Integrate Habitat runtime: add habitat/__main__.py --- habitat/__main__.py | 543 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 543 insertions(+) create mode 100644 habitat/__main__.py diff --git a/habitat/__main__.py b/habitat/__main__.py new file mode 100644 index 0000000..9d94473 --- /dev/null +++ b/habitat/__main__.py @@ -0,0 +1,543 @@ +from __future__ import annotations +import argparse, json, os, sys, secrets +from pathlib import Path +from .schema import Habitat, Job, Action, utcnow +from .store import Store +from .config import load_config, validate_config +from .runtime import run_once, run_loop +from .claims import Claim +from .verify import verify_claim +from .verify_api import proof_status, verify_and_prove +from .adapters import JSONFileEvidenceAdapter, HTTPJSONEvidenceAdapter, GitHubIssueEvidenceAdapter +from .events import signature_for, ingest_event +from .server import serve +from .generate import render_dashboard, write_dashboard +from .doctor import diagnose +from .backup import backup, restore +from .proof_sign import sign_proof +from .proof_card import render_proof_card +from .verdicts import explain_reason, boundary_sentence + +DEFAULT_DB = Path(".habitat/habitat.db") + + +def get_store(a): + return Store(a.db) + + +def cmd_init(a): + cfg = validate_config(load_config(a.config)) + s = Store(a.db) + now = utcnow() + s.save_habitat(Habitat(cfg["id"], cfg["name"], cfg.get("model", "unknown"), now, now)) + for j in cfg.get("jobs", []): + s.save_job( + Job( + j["id"], + cfg["id"], + j["name"], + j.get("schedule"), + j.get("enabled", True), + j.get("command"), + ) + ) + s.close() + print(f"Initialized Habitat {cfg['id']} at {a.db}") + return 0 + + +DEMO_RUN_ID = "run-42" +DEMO_ACTION = "run_job" +DEMO_CLAIM = "deployed v1.2.3 to production" +DEMO_PROVE_COMMAND = ( + f'habitat prove --run-id {DEMO_RUN_ID} --claim "{DEMO_CLAIM}" ' + "-o proof.json --card" +) + + +def cmd_demo(a): + """Record one clearly-labelled sample action so the README prove flow works.""" + s = get_store(a) + try: + h = s.habitat() + existing = s.matching_actions(h.id, action=DEMO_ACTION, run_id=DEMO_RUN_ID) + if existing: + print( + json.dumps( + { + "run_id": DEMO_RUN_ID, + "action": DEMO_ACTION, + "status": existing[0].status, + "sample": True, + "seeded": False, + "next": DEMO_PROVE_COMMAND, + }, + indent=2, + ) + ) + return 0 + jobs = s.jobs() + job_id = jobs[0].id if jobs else None + s.save_action( + Action( + id="demo-run-42-action", + habitat_id=h.id, + timestamp=utcnow(), + actor="agent", + action=DEMO_ACTION, + status="ok", + job_id=job_id, + details={"sample": True, "target": "production", "version": "1.2.3"}, + run_id=DEMO_RUN_ID, + ) + ) + print( + json.dumps( + { + "run_id": DEMO_RUN_ID, + "action": DEMO_ACTION, + "job_id": job_id, + "status": "ok", + "sample": True, + "seeded": True, + "boundary": boundary_sentence("demo"), + "next": DEMO_PROVE_COMMAND, + }, + indent=2, + ) + ) + print() + print("Sample demo data recorded (not a live verification).") + print(f"Next: {DEMO_PROVE_COMMAND}") + return 0 + except RuntimeError as exc: + print( + json.dumps( + { + "ok": False, + "status": "rejected", + "error": "not_initialized", + "hint": "habitat init --config examples/simple.yaml", + "detail": str(exc), + **(explain_reason("not_initialized") or {}), + } + ) + ) + return 1 + finally: + s.close() + + +def cmd_status(a): + s = get_store(a) + h = s.habitat() + print( + json.dumps( + { + "id": h.id, + "name": h.name, + "status": h.status, + "jobs": len(s.jobs()), + "active_signals": len(s.active_signals()), + "agents": len(s.agents()), + }, + indent=2, + ) + ) + s.close() + return 0 + + +def cmd_run(a): + s = get_store(a) + try: + if a.watch: + run_loop(s, interval=a.interval, stop_after=a.duration) + else: + run_once(s) + finally: + s.close() + return 0 + + +def cmd_inspect(a): + s = get_store(a) + print( + json.dumps( + { + "habitat": s.habitat().__dict__, + "jobs": [j.__dict__ for j in s.jobs()], + "signals": [x.__dict__ for x in s.signals()], + }, + default=str, + indent=2, + ) + ) + s.close() + return 0 + + +def cmd_claim(a): + s = get_store(a) + c = Claim.new(s.habitat().id, a.claim, a.job, a.action, a.expected_status) + c.run_id = a.run_id + s.save_claim(c) + print(json.dumps({"id": c.id, "status": c.status, "run_id": c.run_id, "claim": c.claim}, default=str)) + s.close() + return 0 + + +def _evidence(a): + if a.evidence_file: + return JSONFileEvidenceAdapter(a.evidence_file) + if a.evidence_url: + return HTTPJSONEvidenceAdapter(a.evidence_url, a.token_env) + if a.github_issue: + return GitHubIssueEvidenceAdapter(a.token_env) + return None + + +def cmd_verify(a): + s = get_store(a) + c = s.get_claim(a.id) if a.id else (s.claims(1)[0] if s.claims(1) else None) + if not c: + print("No claim found") + s.close() + return 1 + expected = json.loads(a.evidence_expected) if a.evidence_expected else None + c = verify_claim(s, c, _evidence(a), a.query or a.github_issue, expected) + result = {"id": c.id, "status": c.status, "evidence": c.evidence} + explanation = explain_reason(c.evidence.get("reason") if isinstance(c.evidence, dict) else None) + if explanation: + result["explanation"] = explanation + boundary = boundary_sentence(c.status) + if boundary: + result["boundary"] = boundary + print(json.dumps(result, default=str)) + s.close() + return 0 + + +def cmd_proof(a): + s = get_store(a) + try: + result = verify_and_prove(s, a.id) if a.reverify else proof_status(s, a.id) + proof = result["proof"] + if a.signing_key_env: + private_key = os.getenv(a.signing_key_env) + if not private_key: + raise ValueError(f"Signing key environment variable is not set: {a.signing_key_env}") + proof = sign_proof(proof, private_key, a.key_id, a.agent_id) + result["proof"] = proof + result["signed"] = True + else: + result["signed"] = False + if a.output: + Path(a.output).write_text( + json.dumps(proof, default=str, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"Wrote proof to {a.output}") + else: + print(json.dumps(result, default=str, indent=2)) + return 0 + except KeyError: + print(json.dumps({"error": "claim_not_found"})) + return 1 + except (RuntimeError, ValueError) as exc: + print(json.dumps({"error": str(exc)})) + return 1 + finally: + s.close() + + +def cmd_prove(a): + """Create a claim for a run, verify it, and export a portable proof (+ optional card).""" + s = get_store(a) + try: + if not a.run_id and not a.claim: + print(json.dumps({"error": "run_id_or_claim_required"})) + return 2 + + claim_text = a.claim or f"run {a.run_id} completed successfully" + c = Claim.new( + s.habitat().id, + claim_text, + job_id=a.job, + action=a.action or "run_job", + expected_status=a.expected_status or "ok", + ) + c.run_id = a.run_id + s.save_claim(c) + + c = verify_claim(s, c) + if c.status != "verified": + result = { + "claim_id": c.id, + "status": c.status, + "evidence": c.evidence, + "error": "claim_not_verified", + } + reason = c.evidence.get("reason") if isinstance(c.evidence, dict) else None + explanation = explain_reason(reason) + if explanation: + result["reason"] = reason + result["explanation"] = explanation + result["hint"] = explanation["next_action"] + boundary = boundary_sentence(c.status) + if boundary: + result["boundary"] = boundary + print(json.dumps(result, default=str, indent=2)) + if explanation: + print() + print(f"Verdict: {c.status} — {explanation['meaning']}") + print(f"What would change this verdict: {explanation['what_would_change']}") + print(f"Next action: {explanation['next_action']}") + return 1 + + result = verify_and_prove(s, c.id) if a.reverify else proof_status(s, c.id) + proof = result["proof"] + + if a.signing_key_env: + private_key = os.getenv(a.signing_key_env) + if not private_key: + raise ValueError(f"Signing key environment variable is not set: {a.signing_key_env}") + proof = sign_proof(proof, private_key, a.key_id, a.agent_id) + result["proof"] = proof + result["signed"] = True + else: + result["signed"] = False + + # --card must never reference a proof file that was not written: + # without an explicit -o, default to proof.json so the card's + # verify command names a file that actually exists (UX-06). + if a.card and not a.output: + a.output = "proof.json" + + if a.output: + Path(a.output).write_text( + json.dumps(proof, default=str, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"Wrote proof → {a.output}") + + if a.card: + card = render_proof_card( + proof, {"valid": True, "verdict": c.status}, proof_filename=a.output or "proof.json" + ) + card_path = Path(a.output).with_suffix(".md") if a.output else Path("proof.md") + card_path.write_text(card, encoding="utf-8") + print(f"Wrote proof card → {card_path}") + print() + print(card) + + print( + json.dumps( + { + "claim_id": c.id, + "status": c.status, + "run_id": c.run_id, + "proof_file": a.output, + "signed": result.get("signed", False), + "boundary": boundary_sentence(c.status), + }, + indent=2, + default=str, + ) + ) + return 0 + except Exception as exc: + print(json.dumps({"error": str(exc)})) + return 1 + finally: + s.close() + + +def cmd_event(a): + s = get_store(a) + body = Path(a.file).read_bytes() if a.file else a.json.encode() + secret = os.getenv(a.secret_env) if a.secret_env else None + agent_secret = os.getenv(a.agent_secret_env) if a.agent_secret_env else None + sig = a.signature or (signature_for(secret, body) if secret else None) + try: + print(json.dumps(ingest_event(s, body, sig, secret, not a.no_signature, a.agent_id, agent_secret), default=str)) + return 0 + except (ValueError, PermissionError) as e: + print(json.dumps({"accepted": False, "error": str(e)})) + return 1 + finally: + s.close() + + +def cmd_agent_add(a): + s = get_store(a) + h = s.habitat() + secret = os.getenv(a.secret_env) if a.secret_env else None + secret = secret or secrets.token_urlsafe(32) + s.save_agent(a.id, h.id, a.name, True, a.permission, secret) + s.close() + print(json.dumps({"agent_id": a.id, "secret": secret, "permissions": a.permission})) + return 0 + + +def cmd_agents(a): + s = get_store(a) + print(json.dumps(s.agents(), indent=2, default=str)) + s.close() + return 0 + + +def cmd_doctor(a): + checks = diagnose(a.db, a.config) + bad = False + for n, ok, msg in checks: + print(f"{'✓' if ok else '✗'} {n}: {msg}") + bad |= not ok + print("Result:", "NEEDS ATTENTION" if bad else "HEALTHY") + return 1 if bad else 0 + + +def cmd_backup(a): + print(f"Backup written to {backup(a.db, a.output)}") + return 0 + + +def cmd_restore(a): + print(f"Restored database to {restore(a.input, a.db)}") + return 0 + + +def cmd_generate(a): + s = get_store(a) + try: + h = s.habitat() + p = write_dashboard( + render_dashboard(h, s.jobs(), s.signals(), s.actions(), h.name, s.claims()), + a.output, + ) + print(f"Wrote {p.resolve()}") + return 0 + finally: + s.close() + + +def main(argv=None): + p = argparse.ArgumentParser( + prog="habitat", + description="Local-first supervision and accountability runtime for autonomous agents.", + ) + p.add_argument("--db", default=str(DEFAULT_DB)) + sub = p.add_subparsers(dest="command", required=True) + + x = sub.add_parser("init") + x.add_argument("--config", required=True) + x.set_defaults(func=cmd_init) + + x = sub.add_parser( + "demo", + aliases=["seed"], + help="Record one sample action (run-42) so the README prove flow works on a fresh DB", + ) + x.set_defaults(func=cmd_demo) + + sub.add_parser("status").set_defaults(func=cmd_status) + + x = sub.add_parser("run") + x.add_argument("--watch", action="store_true") + x.add_argument("--interval", type=float, default=10) + x.add_argument("--duration", type=float) + x.set_defaults(func=cmd_run) + + sub.add_parser("inspect").set_defaults(func=cmd_inspect) + + x = sub.add_parser("generate") + x.add_argument("-o", "--output", default="dashboard.html") + x.set_defaults(func=cmd_generate) + + x = sub.add_parser("claim") + x.add_argument("claim") + x.add_argument("--job") + x.add_argument("--action") + x.add_argument("--expected-status", default="ok") + x.add_argument("--run-id") + x.set_defaults(func=cmd_claim) + + x = sub.add_parser("verify") + x.add_argument("--id") + x.add_argument("--query") + x.add_argument("--evidence-file") + x.add_argument("--evidence-url") + x.add_argument("--github-issue") + x.add_argument("--token-env", default="GITHUB_TOKEN") + x.add_argument("--evidence-expected") + x.set_defaults(func=cmd_verify) + + x = sub.add_parser("proof") + x.add_argument("id") + x.add_argument("--reverify", action="store_true") + x.add_argument("-o", "--output", help="write only the portable proof bundle to this JSON file") + x.add_argument("--signing-key-env", help="environment variable containing a base64 Ed25519 private key") + x.add_argument("--key-id", help="trusted-key registry identifier to embed in the signature") + x.add_argument("--agent-id", help="publisher agent identifier to bind into the signature") + x.set_defaults(func=cmd_proof) + + x = sub.add_parser("prove", help="Verify a claim for a run and export a portable proof") + x.add_argument("--claim", help="Human-readable claim text") + x.add_argument("--run-id", help="Bind the claim to this Habitat run ID") + x.add_argument("--job", help="Optional job ID") + x.add_argument("--action", help="Expected action name (default: run_job)") + x.add_argument("--expected-status", default="ok") + x.add_argument("-o", "--output", help="Write portable proof.json to this path") + x.add_argument("--card", action="store_true", help="Also write a short markdown proof card (defaults -o to proof.json if unset)") + x.add_argument("--reverify", action="store_true") + x.add_argument("--signing-key-env") + x.add_argument("--key-id") + x.add_argument("--agent-id") + x.set_defaults(func=cmd_prove) + + x = sub.add_parser("event") + x.add_argument("--file") + x.add_argument("--json", default="") + x.add_argument("--signature") + x.add_argument("--secret-env", default="HABITAT_WEBHOOK_SECRET") + x.add_argument("--agent-id") + x.add_argument("--agent-secret-env", default="HABITAT_AGENT_SECRET") + x.add_argument("--no-signature", action="store_true") + x.set_defaults(func=cmd_event) + + sub.add_parser("agents").set_defaults(func=cmd_agents) + + x = sub.add_parser("agent-add") + x.add_argument("id") + x.add_argument("name") + x.add_argument("--permission", action="append", default=["submit_events"]) + x.add_argument("--secret-env", default="HABITAT_AGENT_SECRET") + x.set_defaults(func=cmd_agent_add) + + x = sub.add_parser("doctor") + x.add_argument("--config") + x.set_defaults(func=cmd_doctor) + + x = sub.add_parser("backup") + x.add_argument("output") + x.set_defaults(func=cmd_backup) + + x = sub.add_parser("restore") + x.add_argument("input") + x.set_defaults(func=cmd_restore) + + x = sub.add_parser("serve") + x.add_argument("--host", default="127.0.0.1") + x.add_argument("--port", type=int, default=8787) + x.add_argument("--secret-env", default="HABITAT_WEBHOOK_SECRET") + x.add_argument("--allow-unsigned", action="store_true") + x.set_defaults( + func=lambda a: serve(a.db, a.host, a.port, os.getenv(a.secret_env), not a.allow_unsigned) + ) + + args = p.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main(argv=None)) From 0411219c46d09bfac6f68a80e2d07aed533cba27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:47:11 +0200 Subject: [PATCH 03/99] Integrate Habitat runtime: add habitat/adapters.py --- habitat/adapters.py | 209 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 habitat/adapters.py diff --git a/habitat/adapters.py b/habitat/adapters.py new file mode 100644 index 0000000..7ae4520 --- /dev/null +++ b/habitat/adapters.py @@ -0,0 +1,209 @@ +"""Integration boundaries for Habitat runtimes and evidence sources. + +Adapters are intentionally small and stdlib-only. They let Habitat verify claims +against systems outside its own process without making those systems part of the +core domain model. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +import ipaddress +import json +import os +from pathlib import Path +import re +import shlex +import socket +import subprocess +from typing import Any, Protocol +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import HTTPRedirectHandler, Request, build_opener + +MAX_EVIDENCE_RESPONSE_BYTES = 1_048_576 +_SAFE_GITHUB_NAME = re.compile(r"^[A-Za-z0-9_.-]+$") + + +class _NoRedirectHandler(HTTPRedirectHandler): + """Do not silently turn an evidence URL into an attacker-chosen URL.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise URLError("redirect_not_allowed") + + +_NO_REDIRECT_OPENER = build_opener(_NoRedirectHandler) + + +def _validate_http_url(url: str) -> None: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"}: + raise ValueError("unsupported_evidence_url_scheme") + if not parsed.hostname: + raise ValueError("evidence_url_missing_host") + if parsed.username is not None or parsed.password is not None: + raise ValueError("evidence_url_credentials_not_allowed") + + # Evidence URLs are configuration, but a compromised/untrusted configuration + # must not turn the adapter into an easy SSRF primitive. Resolve every address + # before opening the URL and reject any hostname that can resolve to a local, + # private, link-local, loopback, multicast, reserved, or unspecified address. + try: + addresses = {item[4][0] for item in socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM)} + except socket.gaierror as exc: + raise ValueError("evidence_url_host_unresolvable") from exc + if not addresses: + raise ValueError("evidence_url_host_unresolvable") + for address in addresses: + try: + ip = ipaddress.ip_address(address) + except ValueError as exc: + raise ValueError("evidence_url_invalid_address") from exc + if not ip.is_global: + raise ValueError("evidence_url_private_address") + + +def _read_limited(response) -> bytes: + content_length = response.headers.get("Content-Length") + if content_length: + try: + if int(content_length) > MAX_EVIDENCE_RESPONSE_BYTES: + raise ValueError("evidence_response_too_large") + except ValueError: + if content_length != "0": + raise ValueError("invalid_content_length") + data = response.read(MAX_EVIDENCE_RESPONSE_BYTES + 1) + if len(data) > MAX_EVIDENCE_RESPONSE_BYTES: + raise ValueError("evidence_response_too_large") + return data + + +@dataclass +class ExecutionResult: + ok: bool + stdout: str = "" + stderr: str = "" + exit_code: int | None = None + + +class JobAdapter(Protocol): + def execute(self, command: str, timeout: int = 300) -> ExecutionResult: ... + + +class ShellAdapter: + """Local command adapter. Safe argv execution is the default; shell mode is explicit.""" + + def __init__(self, allow_shell: bool = False): + self.allow_shell = allow_shell + + def execute(self, command: str, timeout: int = 300) -> ExecutionResult: + try: + argv = command if self.allow_shell else shlex.split(command) + p = subprocess.run(argv, shell=self.allow_shell, capture_output=True, text=True, timeout=timeout) + return ExecutionResult(p.returncode == 0, p.stdout[-4000:], p.stderr[-4000:], p.returncode) + except subprocess.TimeoutExpired as exc: + return ExecutionResult(False, str(exc.stdout or "")[-4000:], "command timed out", None) + except Exception as exc: + return ExecutionResult(False, "", str(exc)[-4000:], None) + + +@dataclass +class Evidence: + """Normalized evidence returned by an external system.""" + + source: str + observed: bool + status: str | None = None + data: dict[str, Any] = field(default_factory=dict) + error: str | None = None + + +class EvidenceAdapter(Protocol): + def observe(self, query: str) -> Evidence: ... + + +class JSONFileEvidenceAdapter: + """Read deterministic evidence from a local JSON document.""" + + def __init__(self, path: str | Path): + self.path = Path(path) + + def observe(self, query: str) -> Evidence: + try: + payload = json.loads(self.path.read_text(encoding="utf-8")) + value: Any = payload + for part in query.split(".") if query else []: + if not isinstance(value, dict) or part not in value: + return Evidence(str(self.path), False, error=f"path_not_found:{query}") + value = value[part] + if isinstance(value, dict): + return Evidence(str(self.path), True, value.get("status"), value) + return Evidence(str(self.path), True, str(value) if value is not None else None, {"value": value}) + except Exception as exc: + return Evidence(str(self.path), False, error=str(exc)) + + +class HTTPJSONEvidenceAdapter: + """Fetch bounded JSON evidence from a fixed HTTP endpoint without redirects.""" + + def __init__(self, base_url: str, token_env: str | None = None, timeout: int = 15): + _validate_http_url(base_url) + if timeout <= 0 or timeout > 300: + raise ValueError("invalid_evidence_timeout") + self.base_url = base_url.rstrip("/") + self.token_env = token_env + self.timeout = timeout + + def observe(self, query: str) -> Evidence: + req = Request(self.base_url, headers={"Accept": "application/json", "User-Agent": "Habitat/1.0"}) + token = os.getenv(self.token_env) if self.token_env else None + if token: + req.add_header("Authorization", f"Bearer {token}") + try: + with _NO_REDIRECT_OPENER.open(req, timeout=self.timeout) as response: + payload = json.loads(_read_limited(response).decode("utf-8"), parse_constant=lambda value: (_ for _ in ()).throw(ValueError("invalid_json_constant"))) + value: Any = payload + for part in query.split(".") if query else []: + if not isinstance(value, dict) or part not in value: + return Evidence(self.base_url, False, error=f"path_not_found:{query}") + value = value[part] + if isinstance(value, dict): + return Evidence(self.base_url, True, value.get("status"), value) + return Evidence(self.base_url, True, str(value) if value is not None else None, {"value": value}) + except HTTPError as exc: + return Evidence(self.base_url, False, error=f"http_{exc.code}") + except (URLError, TimeoutError, ValueError) as exc: + return Evidence(self.base_url, False, error=str(exc)) + + +class GitHubIssueEvidenceAdapter: + """Verify that a GitHub issue exists. Query format: owner/repo#number.""" + + def __init__(self, token_env: str = "GITHUB_TOKEN", timeout: int = 15): + if timeout <= 0 or timeout > 300: + raise ValueError("invalid_evidence_timeout") + self.token_env = token_env + self.timeout = timeout + + def observe(self, query: str) -> Evidence: + try: + repo, number = query.rsplit("#", 1) + owner, name = repo.split("/", 1) + if not _SAFE_GITHUB_NAME.fullmatch(owner) or not _SAFE_GITHUB_NAME.fullmatch(name): + raise ValueError("invalid_github_repository") + issue_number = int(number) + if issue_number <= 0: + raise ValueError("invalid_github_issue_number") + url = f"https://api.github.com/repos/{owner}/{name}/issues/{issue_number}" + req = Request(url, headers={"Accept": "application/vnd.github+json", "User-Agent": "Habitat/1.0"}) + token = os.getenv(self.token_env) + if token: + req.add_header("Authorization", f"Bearer {token}") + with _NO_REDIRECT_OPENER.open(req, timeout=self.timeout) as response: + payload = json.loads(_read_limited(response).decode("utf-8"), parse_constant=lambda value: (_ for _ in ()).throw(ValueError("invalid_json_constant"))) + return Evidence("github", True, "ok", {"number": payload.get("number"), "state": payload.get("state"), "title": payload.get("title"), "url": payload.get("html_url")}) + except HTTPError as exc: + if exc.code == 404: + return Evidence("github", False, error="issue_not_found") + return Evidence("github", False, error=f"http_{exc.code}") + except (ValueError, URLError, TimeoutError) as exc: + return Evidence("github", False, error=str(exc)) From 929afaabd3f155852d8a426e4010c42189420d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:47:14 +0200 Subject: [PATCH 04/99] Integrate Habitat runtime: add habitat/anchor.py --- habitat/anchor.py | 75 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 habitat/anchor.py diff --git a/habitat/anchor.py b/habitat/anchor.py new file mode 100644 index 0000000..9d97f11 --- /dev/null +++ b/habitat/anchor.py @@ -0,0 +1,75 @@ +"""Optional external tip anchoring for Habitat proofs. + +Local SHA-256 chains are tamper-evident within the producer's database. +An external tip raises the bar against whole-ledger rewrite by recording the +current tip digest outside the Habitat database (git notes, a tip file, or a +simple public location). This module stays local-first and optional. + +Habitat still does not claim external-world truth or remote attestation. +""" +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def tip_digest(proof_or_ledger: dict[str, Any]) -> str: + """Compute a stable tip digest from a proof bundle or a ledger object.""" + if "content_sha256" in proof_or_ledger and isinstance(proof_or_ledger["content_sha256"], str): + return proof_or_ledger["content_sha256"] + actions = [] + if isinstance(proof_or_ledger.get("ledger"), dict): + actions = proof_or_ledger["ledger"].get("actions") or [] + elif isinstance(proof_or_ledger.get("actions"), list): + actions = proof_or_ledger["actions"] + payload = json.dumps(actions, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def write_tip_file( + tip: str, + path: str | Path, + *, + claim_id: str | None = None, + run_id: str | None = None, + proof_path: str | None = None, +) -> Path: + """Write a small tip record that a verifier or human can inspect later.""" + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + record = { + "habitat_tip_version": 1, + "recorded_at": datetime.now(timezone.utc).isoformat(), + "tip": tip, + "claim_id": claim_id, + "run_id": run_id, + "proof_path": proof_path, + } + out.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return out + + +def read_tip_file(path: str | Path) -> dict[str, Any]: + data = json.loads(Path(path).read_text(encoding="utf-8")) + if not isinstance(data, dict) or data.get("habitat_tip_version") != 1: + raise ValueError("not a Habitat tip file") + if not isinstance(data.get("tip"), str) or len(data["tip"]) != 64: + raise ValueError("tip digest missing or malformed") + return data + + +def check_tip(proof: dict[str, Any], tip_path: str | Path) -> dict[str, Any]: + """Compare a proof's content digest against an external tip file.""" + expected = read_tip_file(tip_path) + actual = proof.get("content_sha256") + match = isinstance(actual, str) and actual == expected["tip"] + return { + "match": match, + "expected_tip": expected["tip"], + "actual_tip": actual, + "tip_file": str(tip_path), + "recorded_at": expected.get("recorded_at"), + } From 0c7203032ada6c83c69272cc2277722a097c1984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:47:17 +0200 Subject: [PATCH 05/99] Integrate Habitat runtime: add habitat/backup.py --- habitat/backup.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 habitat/backup.py diff --git a/habitat/backup.py b/habitat/backup.py new file mode 100644 index 0000000..870ea16 --- /dev/null +++ b/habitat/backup.py @@ -0,0 +1,14 @@ +from pathlib import Path +import sqlite3 + +def backup(db_path,destination): + src=sqlite3.connect(db_path); dst_path=Path(destination); dst_path.parent.mkdir(parents=True,exist_ok=True); dst=sqlite3.connect(dst_path) + try:src.backup(dst) + finally:dst.close();src.close() + return dst_path + +def restore(source,db_path): + src=sqlite3.connect(source); dst=sqlite3.connect(db_path) + try:src.backup(dst) + finally:dst.close();src.close() + return Path(db_path) From 2ea3507f1339e336ed52542fcbd3b6a77b688f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:47:20 +0200 Subject: [PATCH 06/99] Integrate Habitat runtime: add habitat/claims.py --- habitat/claims.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 habitat/claims.py diff --git a/habitat/claims.py b/habitat/claims.py new file mode 100644 index 0000000..c31c37c --- /dev/null +++ b/habitat/claims.py @@ -0,0 +1,28 @@ +"""Agent claims: assertions that Habitat can verify against trusted state.""" +from __future__ import annotations +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Literal +import uuid +from .schema import utcnow + +ClaimStatus = Literal["pending", "verified", "rejected", "inconclusive"] + +@dataclass +class Claim: + id: str + habitat_id: str + job_id: str | None + claim: str + action: str | None = None + expected_status: str = "ok" + created_at: datetime = field(default_factory=utcnow) + verified_at: datetime | None = None + status: ClaimStatus = "pending" + evidence: dict[str, Any] = field(default_factory=dict) + run_id: str | None = None + + @classmethod + def new(cls, habitat_id: str, claim: str, job_id: str | None = None, + action: str | None = None, expected_status: str = "ok") -> "Claim": + return cls(str(uuid.uuid4()), habitat_id, job_id, claim, action, expected_status) From 441087c5119d43f0d0ebd96dc3b09940286da351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:47:22 +0200 Subject: [PATCH 07/99] Integrate Habitat runtime: add habitat/config.py --- habitat/config.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 habitat/config.py diff --git a/habitat/config.py b/habitat/config.py new file mode 100644 index 0000000..a689443 --- /dev/null +++ b/habitat/config.py @@ -0,0 +1,51 @@ +"""Dependency-free configuration loader and validator.""" +from __future__ import annotations +import json +from pathlib import Path + +def _scalar(value): + value=value.strip() + if not value:return None + if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):return value[1:-1] + if value.lower() in {'true','false'}:return value.lower()=='true' + if value.lower() in {'null','none'}:return None + return value + +def load_yaml(path): + lines=Path(path).read_text(encoding='utf-8').splitlines(); result={}; jobs=[]; in_jobs=False; current=None + for raw in lines: + line=raw.split('#',1)[0].rstrip(); stripped=line.strip() + if not stripped:continue + if stripped=='jobs:':in_jobs=True;result['jobs']=jobs;continue + if in_jobs and stripped.startswith('- '): + current={};jobs.append(current); item=stripped[2:].strip() + if item:key,value=item.split(':',1);current[key.strip()]=_scalar(value) + continue + if in_jobs and line.startswith(' ') and current is not None and ':' in stripped: + key,value=stripped.split(':',1);current[key.strip()]=_scalar(value);continue + in_jobs=False + if ':' in stripped:key,value=stripped.split(':',1);result[key.strip()]=_scalar(value) + return result + +def load_config(path): + p=Path(path) + if p.suffix.lower()=='.json':return json.loads(p.read_text(encoding='utf-8')) + if p.suffix.lower() in {'.yaml','.yml'}:return load_yaml(p) + raise ValueError('Config must be .yaml, .yml, or .json') + +def validate_config(cfg): + if not isinstance(cfg,dict):raise ValueError('configuration must be an object') + if not isinstance(cfg.get('id'),str) or not cfg['id'].strip():raise ValueError('id must be a non-empty string') + if not isinstance(cfg.get('name'),str) or not cfg['name'].strip():raise ValueError('name must be a non-empty string') + jobs=cfg.get('jobs',[]) + if not isinstance(jobs,list):raise ValueError('jobs must be a list') + seen=set() + for i,j in enumerate(jobs): + if not isinstance(j,dict):raise ValueError(f'jobs[{i}] must be an object') + for key in ('id','name'): + if not isinstance(j.get(key),str) or not j[key].strip():raise ValueError(f'jobs[{i}].{key} must be a non-empty string') + if j['id'] in seen:raise ValueError(f'duplicate job id: {j["id"]}') + seen.add(j['id']) + if 'enabled' in j and not isinstance(j['enabled'],bool):raise ValueError(f'jobs[{i}].enabled must be boolean') + if j.get('command') is not None and not isinstance(j['command'],str):raise ValueError(f'jobs[{i}].command must be string') + return cfg From e697c2a46eb07c4855c601533e01c0f6919c0958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:47:25 +0200 Subject: [PATCH 08/99] Integrate Habitat runtime: add habitat/doctor.py --- habitat/doctor.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 habitat/doctor.py diff --git a/habitat/doctor.py b/habitat/doctor.py new file mode 100644 index 0000000..ae44b9b --- /dev/null +++ b/habitat/doctor.py @@ -0,0 +1,25 @@ +from __future__ import annotations +import os, sys +from .store import Store + +def diagnose(db_path, config_path=None): + checks = [] + try: + s = Store(db_path) + s.habitat() + version = s.conn.execute("SELECT value FROM schema_meta WHERE key='version'").fetchone()[0] + integrity_ok = s.verify_action_integrity() + checks.append(('database', True, 'SQLite readable')) + checks.append(('schema', True, f'version {version}')) + checks.append(('action_integrity', integrity_ok, 'hash chain intact' if integrity_ok else 'action hash chain failed')) + config_ok = config_path is None or os.path.exists(config_path) + checks.append(('configuration', config_ok, 'present' if config_ok else 'missing')) + checks.append(('jobs', True, f'{len(s.jobs())} configured')) + checks.append(('agents', True, f'{len(s.agents())} registered')) + s.close() + except Exception as exc: + checks.append(('database', False, str(exc))) + checks.append(('python', sys.version_info >= (3, 10), sys.version.split()[0])) + secret_set = bool(os.getenv('HABITAT_WEBHOOK_SECRET')) + checks.append(('signing_secret', secret_set, 'set' if secret_set else 'not set; configure HABITAT_WEBHOOK_SECRET before starting signed event ingestion')) + return checks From 00896bed1188f10e3a7844b56ec9c6b06dd358db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:47:28 +0200 Subject: [PATCH 09/99] Integrate Habitat runtime: add habitat/events.py --- habitat/events.py | 132 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 habitat/events.py diff --git a/habitat/events.py b/habitat/events.py new file mode 100644 index 0000000..ba3b473 --- /dev/null +++ b/habitat/events.py @@ -0,0 +1,132 @@ +"""Secure structured event ingestion.""" +from __future__ import annotations + +import hashlib +import hmac +import json +import uuid + +from .claims import Claim +from .schema import Action, utcnow +from .store import Store +from .verify import verify_claim + +MAX_EVENT_BYTES = 256 * 1024 +ALLOWED_TYPES = {"agent.heartbeat", "job.started", "job.completed", "job.failed", "claim.submitted"} +PERMISSIONS = {"agent.heartbeat": "submit_events", "job.started": "submit_events", "job.completed": "submit_events", "job.failed": "submit_events", "claim.submitted": "submit_claims"} + + +def signature_for(secret, body): + return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + + +def valid_signature(secret, body, supplied): + if not secret or not supplied: + return False + normalized = supplied.removeprefix("sha256=").strip() + if len(normalized) != 64: + return False + try: + int(normalized, 16) + except ValueError: + return False + return hmac.compare_digest(signature_for(secret, body), normalized) + + +def _reject_constant(value): + raise ValueError(f"non_standard_json_number:{value}") + + +def _validate(p, store): + eid, typ, hid = p.get("id"), p.get("type"), p.get("habitat_id") + if not isinstance(eid, str) or not eid.strip() or len(eid) > 200: + raise ValueError("invalid_id") + if typ not in ALLOWED_TYPES: + raise ValueError("unsupported_event_type") + if not isinstance(hid, str) or not hid: + raise ValueError("missing_habitat_id") + if store.habitat().id != hid: + raise ValueError("habitat_id_mismatch") + if typ == "agent.heartbeat": + return eid, typ, hid + if typ == "claim.submitted": + if not isinstance(p.get("claim"), str) or not p["claim"].strip(): + raise ValueError("missing_claim") + if p.get("job_id") is not None and not isinstance(p["job_id"], str): + raise ValueError("invalid_job_id") + else: + if not isinstance(p.get("job_id"), str): + raise ValueError("missing_job_id") + if not any(j.id == p["job_id"] for j in store.jobs()): + raise ValueError("unknown_job_id") + return eid, typ, hid + + +def ingest_event(store, body, signature, secret, require_signature=True, agent_id=None, agent_secret=None): + if len(body) > MAX_EVENT_BYTES: + raise ValueError("event_too_large") + try: + p = json.loads(body.decode("utf-8"), parse_constant=_reject_constant) + except Exception as exc: + raise ValueError("invalid_json") from exc + if not isinstance(p, dict): + raise ValueError("event_must_be_object") + eid, typ, hid = _validate(p, store) + ok = valid_signature(secret or "", body, signature) + if require_signature and not ok: + raise PermissionError("invalid_signature") + payload_agent = p.get("agent_id") + if payload_agent is not None and not isinstance(payload_agent, str): + raise ValueError("invalid_agent_id") + if agent_id is not None and payload_agent is not None and payload_agent != agent_id: + raise PermissionError("agent_identity_mismatch") + declared_agent = payload_agent or agent_id + + # Event acceptance, agent last-seen state, derived actions/jobs, and claims + # are one transaction. A downstream failure must not leave a duplicate event + # that causes the retry to be discarded while its side effects are missing. + with store.transaction(immediate=True): + if declared_agent: + if not agent_secret: + raise PermissionError("agent_secret_required") + if not store.authenticate_agent(declared_agent, agent_secret, PERMISSIONS[typ]): + raise PermissionError("agent_not_authorized") + inserted = store.save_event(eid, hid, typ, utcnow().isoformat(), ok, p, declared_agent) + result = {"accepted": True, "duplicate": not inserted, "id": eid, "type": typ} + if not inserted: + return result + correlation = p.get("correlation_id") or p.get("run_id") or eid + if typ == "agent.heartbeat": + result["agent_id"] = declared_agent + result["heartbeat"] = True + return result + if typ in {"job.completed", "job.failed"}: + job = next(j for j in store.jobs() if j.id == p["job_id"]) + status = "ok" if typ == "job.completed" else "failed" + now = utcnow() + store.save_action(Action(str(uuid.uuid4()), hid, now, declared_agent or "agent", "run_job", status, job.id, {"source": "event", "event_id": eid, "payload": p}, correlation)) + job.last_run_at = now + job.last_status = status + job.last_error = p.get("error") if status == "failed" else None + job.failure_streak = job.failure_streak + 1 if status == "failed" else 0 + store.save_job(job) + result.update(job_status=status, run_id=correlation) + elif typ == "job.started": + job = next(j for j in store.jobs() if j.id == p["job_id"]) + job.last_status = "running" + job.last_run_at = utcnow() + store.save_job(job) + result.update(job_status="running", run_id=correlation) + else: + c = Claim.new(hid, p["claim"], p.get("job_id"), p.get("action"), p.get("expected_status", "ok")) + c.run_id = p.get("run_id") or p.get("correlation_id") + if c.job_id and not c.run_id: + c.status = "inconclusive" + c.evidence = {"source": "habitat", "reason": "run_id_required_for_network_claim"} + c.verified_at = utcnow() + store.save_claim(c) + else: + store.save_claim(c) + verify_claim(store, c, None, None, p.get("evidence_expected")) + result.update(claim_id=c.id, claim_status=c.status) + return result From b48eb4dc926cc88f8df2e312fcc298f22a06f1a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:05 +0200 Subject: [PATCH 10/99] Integrate Habitat runtime: add habitat/generate.py --- habitat/generate.py | 115 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 habitat/generate.py diff --git a/habitat/generate.py b/habitat/generate.py new file mode 100644 index 0000000..51f7c29 --- /dev/null +++ b/habitat/generate.py @@ -0,0 +1,115 @@ +"""Render Habitat's public landing page and native supervision surface.""" +from __future__ import annotations + +from html import escape +from pathlib import Path + +from .claims import Claim +from .schema import Action, Habitat, Job, Signal + + +def _page(title: str, body: str) -> str: + return f""" + + + + + +{escape(title)} +{body}""" + + +def render_landing(title: str = "Habitat") -> str: + body = f""" +
Local-first accountability for agents
+

Don't just log what an agent did. Check the evidence.

+

Habitat is a small supervision and evidence runtime for autonomous agents. It turns authenticated events into a verifiable local record, checks claims against that record, and produces portable proof another machine can inspect.

+
+
evidence path·deterministic, local, inspectable
+
Agentexisting framework
Eventauthenticated input
Ledgertamper-evident record
Verifyclaim + evidence
Proofportable artifact
+

A layer for accountability, not another observability suite.

+

Keep your agent framework and existing telemetry. Habitat focuses on the narrower question that matters when an agent makes a claim: what evidence does the system actually have?

+
01 / RECORD

Capture trusted actions

Accept structured, authenticated events and correlate work with run IDs instead of inventing another tracing system.

+
02 / VERIFY

Test claims against evidence

Check exact action/run relationships and explicit external evidence without treating a valid record as proof of real-world truth.

+
03 / EXCHANGE

Export portable proof

Produce a proof artifact that can be verified away from the producer database, including in CI.

+

Habitat is deliberately small. It does not replace tracing, evaluation, orchestration, or human judgment. It gives those systems an accountability surface they can inspect.

+
{escape(title)} · MIT licensedlocal evidence → verification → proof
""" + return _page(f"{title} — Agent accountability", body) + + +def _claim_evidence(claim: Claim) -> str: + evidence = claim.evidence if isinstance(claim.evidence, dict) else {} + if not evidence: + return "No recorded evidence" + parts = [] + for key in ("source", "status", "action_id", "run_id", "adapter"): + value = evidence.get(key) + if value not in (None, ""): + parts.append(f"{escape(str(key))}: {escape(str(value))}") + return " · ".join(parts) if parts else "Recorded evidence" + + +def render_dashboard(habitat: Habitat, jobs: list[Job], signals: list[Signal], actions: list[Action], title: str = "Habitat", claims: list[Claim] | None = None) -> str: + claims = claims or [] + unresolved = [s for s in signals if s.resolved_at is None and s.severity in ("critical", "warning")] + unresolved.sort(key=lambda s: (0 if s.severity == "critical" else 1, s.detected_at)) + enabled = sum(j.enabled for j in jobs) + ok = sum(j.last_status == "ok" for j in jobs) + failed = sum(j.last_status == "failed" for j in jobs) + attention = "".join(f'
{escape(s.severity.upper())}{escape(s.code)} — {escape(s.message)}
' for s in unresolved) or '
All clear. Nothing needs attention.
' + rows = "".join(f'{escape(j.name)}{"enabled" if j.enabled else "disabled"}{escape(j.last_status or "—")}{j.failure_streak}{escape(j.last_error or "—")}' for j in jobs) or 'No jobs configured.' + acts = "".join(f'{escape(a.timestamp.strftime("%Y-%m-%d %H:%M"))}{escape(a.actor)}{escape(a.action)}{escape(a.status)}' for a in actions[:20]) or 'No recent actions.' + claim_cards = "".join( + f'
{escape(c.id)}{escape(c.status.upper())}
' + f'
{escape(c.claim)}
' + f'
Expected{escape(c.expected_status)}
' + f'
Evidence{_claim_evidence(c)}
' + f'
Run{escape(c.run_id or "—")}
' + f'
Job{escape(c.job_id or "—")}
' + f'
' + for c in claims[:20] + ) or '
No claims recorded yet.
' + panel_class = "attention" if unresolved else "calm" + body = f""" +

{escape(title)}

{escape(habitat.status)} · {escape(habitat.model)} · local supervision
What is Habitat?
+
{enabled}/{len(jobs)}
Jobs enabled
{ok}
Successful runs
{failed}
Failed runs
{len(unresolved)}
Needs attention
+

Needs attention

{attention}
+

Claims & proof

{claim_cards}
+

Jobs & health

{rows}
NameStateLast statusFailure streakLast error
+

Recent actions

{acts}
TimeActorActionStatus
""" + return _page(f"{title} — Supervision", body) + + +def write_dashboard(html: str, path: str | Path) -> Path: + p = Path(path) + p.write_text(html, encoding="utf-8") + return p From e21639d458c04f1ab5a7039bac28c5ebc184abb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:09 +0200 Subject: [PATCH 11/99] Integrate Habitat runtime: add habitat/identity.py --- habitat/identity.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 habitat/identity.py diff --git a/habitat/identity.py b/habitat/identity.py new file mode 100644 index 0000000..d5bade4 --- /dev/null +++ b/habitat/identity.py @@ -0,0 +1,45 @@ +"""Agent identity lifecycle primitives. + +Identity is intentionally separate from authorization. A stable agent ID identifies +an actor; permissions and signing-key trust decide what that actor may do. +""" +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Literal + +IdentityState = Literal["active", "disabled", "revoked"] + + +@dataclass(frozen=True) +class AgentIdentity: + agent_id: str + name: str + state: IdentityState = "active" + key_id: str | None = None + created_at: datetime | None = None + disabled_at: datetime | None = None + revoked_at: datetime | None = None + + @property + def usable(self) -> bool: + return self.state == "active" + + def disable(self, now: datetime | None = None) -> "AgentIdentity": + return AgentIdentity(self.agent_id, self.name, "disabled", self.key_id, + self.created_at, now or datetime.now(timezone.utc), self.revoked_at) + + def enable(self) -> "AgentIdentity": + if self.state == "revoked": + raise ValueError("revoked identities cannot be re-enabled; create a new identity") + return AgentIdentity(self.agent_id, self.name, "active", self.key_id, + self.created_at, None, self.revoked_at) + + def revoke(self, now: datetime | None = None) -> "AgentIdentity": + return AgentIdentity(self.agent_id, self.name, "revoked", self.key_id, + self.created_at, self.disabled_at, now or datetime.now(timezone.utc)) + + +def require_active(identity: AgentIdentity) -> None: + if not identity.usable: + raise PermissionError(f"agent_identity_{identity.state}") From 97c5cbca219fa1ba0d646f1bb23fc461e67054e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:11 +0200 Subject: [PATCH 12/99] Integrate Habitat runtime: add habitat/otel.py --- habitat/otel.py | 74 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 habitat/otel.py diff --git a/habitat/otel.py b/habitat/otel.py new file mode 100644 index 0000000..7415ee5 --- /dev/null +++ b/habitat/otel.py @@ -0,0 +1,74 @@ +"""OpenTelemetry-compatible normalization helpers. + +Habitat does not replace OpenTelemetry. It consumes a deliberately small JSON +projection of OTel GenAI spans/events and turns only accountability-relevant +operations into Habitat events. This keeps the core dependency-free while +letting existing instrumented agents feed Habitat without adopting a second +tracing model. +""" +from __future__ import annotations + +from typing import Any, Iterable + + +ACCOUNTABILITY_OPERATIONS = { + "invoke_agent": "agent.heartbeat", + "invoke_workflow": "job.started", + "execute_tool": "job.completed", +} + + +def _attrs(item: dict[str, Any]) -> dict[str, Any]: + attrs = item.get("attributes") or item.get("span_attributes") or {} + if isinstance(attrs, list): + return { + str(x.get("key")): x.get("value", {}).get("stringValue", x.get("value")) + for x in attrs if isinstance(x, dict) and x.get("key") + } + return attrs if isinstance(attrs, dict) else {} + + +def events_from_spans(spans: Iterable[dict[str, Any]], habitat_id: str, job_id: str | None = None) -> list[dict[str, Any]]: + """Convert OTel-style JSON spans into Habitat event envelopes. + + The converter intentionally ignores ordinary model/debug spans. Only + operations that can contribute to an accountability trail are projected. + ``trace_id`` becomes the Habitat ``run_id`` so traces and proofs remain + correlated without copying the whole trace into the ledger. + """ + out: list[dict[str, Any]] = [] + for span in spans: + attrs = _attrs(span) + operation = attrs.get("gen_ai.operation.name") or span.get("name") + event_type = ACCOUNTABILITY_OPERATIONS.get(operation) + if not event_type: + continue + trace_id = span.get("trace_id") or attrs.get("trace_id") + span_id = span.get("span_id") or attrs.get("span_id") + if event_type == "agent.heartbeat": + out.append({"id": f"otel-{span_id or trace_id}", "type": event_type, "habitat_id": habitat_id, "agent_id": attrs.get("gen_ai.agent.id"), "run_id": trace_id}) + continue + if not job_id: + continue + out.append({ + "id": f"otel-{span_id or trace_id}", + "type": event_type, + "habitat_id": habitat_id, + "job_id": job_id, + "run_id": trace_id, + "correlation_id": trace_id, + "action": attrs.get("gen_ai.operation.name") or operation, + "model": attrs.get("gen_ai.request.model") or attrs.get("gen_ai.response.model"), + "status": span.get("status", {}).get("code") if isinstance(span.get("status"), dict) else span.get("status"), + }) + return out + + +def accountability_attributes(event: dict[str, Any]) -> dict[str, Any]: + """Return stable OTel-style attributes for a Habitat event.""" + return { + "habitat.event.type": event.get("type"), + "habitat.habitat_id": event.get("habitat_id"), + "habitat.job_id": event.get("job_id"), + "habitat.run_id": event.get("run_id") or event.get("correlation_id"), + } From fb7810775924e034695d5b7e2402554d69180a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:14 +0200 Subject: [PATCH 13/99] Integrate Habitat runtime: add habitat/policy.py --- habitat/policy.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 habitat/policy.py diff --git a/habitat/policy.py b/habitat/policy.py new file mode 100644 index 0000000..fff378b --- /dev/null +++ b/habitat/policy.py @@ -0,0 +1,68 @@ +"""Deterministic evidence policies for deciding whether a claim is trustworthy enough.""" +from __future__ import annotations +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any + + +@dataclass(frozen=True) +class EvidencePolicy: + """A small, serializable set of requirements for verified claims. + + Policies never create evidence. They only constrain evidence already returned + by Habitat verification, so a policy failure cannot turn missing evidence + into a positive result. + """ + name: str + required_source: str | None = None + required_action: str | None = None + required_status: str | None = None + require_run_id: bool = False + max_age_seconds: int | None = None + max_future_seconds: int = 30 + required_detail_keys: tuple[str, ...] = () + metadata: dict[str, Any] = field(default_factory=dict) + + +def evaluate_policy(policy: EvidencePolicy, evidence: dict[str, Any], now: datetime | None = None) -> tuple[bool, list[str]]: + reasons: list[str] = [] + source = evidence.get("source") + if policy.required_source and source != policy.required_source: + reasons.append("required_source_missing") + if policy.required_action and evidence.get("action") != policy.required_action: + reasons.append("required_action_missing") + if policy.required_status and evidence.get("status") != policy.required_status: + reasons.append("required_status_missing") + if policy.require_run_id and not evidence.get("run_id"): + reasons.append("run_id_required") + if policy.required_detail_keys: + details = evidence.get("details") + if not isinstance(details, dict): + reasons.append("details_required") + else: + for key in policy.required_detail_keys: + if key not in details: + reasons.append(f"detail_missing:{key}") + if policy.max_age_seconds is not None or policy.max_future_seconds is not None: + timestamp = evidence.get("timestamp") + if not timestamp: + if policy.max_age_seconds is not None or policy.max_future_seconds is not None: + reasons.append("timestamp_required") + else: + try: + observed = datetime.fromisoformat(timestamp) + current = now or datetime.now(observed.tzinfo) + age = current - observed + if policy.max_age_seconds is not None and age > timedelta(seconds=policy.max_age_seconds): + reasons.append("evidence_stale") + if policy.max_future_seconds is not None and age < -timedelta(seconds=policy.max_future_seconds): + reasons.append("evidence_from_future") + except (TypeError, ValueError): + reasons.append("invalid_timestamp") + return not reasons, reasons + + +def apply_policy(policy: EvidencePolicy, evidence: dict[str, Any], now: datetime | None = None) -> dict[str, Any]: + """Return a deterministic policy decision without mutating the claim.""" + passed, reasons = evaluate_policy(policy, evidence, now) + return {"policy": policy.name, "status": "allowed" if passed else "denied", "reasons": reasons} From bb5d01697290feac4f58093eaaab9882f183e042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:19 +0200 Subject: [PATCH 14/99] Integrate Habitat runtime: add habitat/policy_presets.py --- habitat/policy_presets.py | 56 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 habitat/policy_presets.py diff --git a/habitat/policy_presets.py b/habitat/policy_presets.py new file mode 100644 index 0000000..d341f75 --- /dev/null +++ b/habitat/policy_presets.py @@ -0,0 +1,56 @@ +"""Common evidence-policy presets for Hermes / agentscope failure modes. + +These presets only constrain evidence Habitat already has. They never invent +positive evidence. Use them when a claim must survive the exact failure modes +already measured by agentscope (config drift, silent skip, auth failure, streak). +""" +from __future__ import annotations + +from .policy import EvidencePolicy + + +def trusted_ledger_with_run() -> EvidencePolicy: + return EvidencePolicy( + name="trusted_ledger_with_run", + required_source="habitat_trusted_ledger", + require_run_id=True, + max_age_seconds=7 * 24 * 3600, + ) + + +def no_drift_skip() -> EvidencePolicy: + """Reject evidence that records a drift_skip or silent skip outcome.""" + return EvidencePolicy( + name="no_drift_skip", + required_source="habitat_trusted_ledger", + required_status="ok", + require_run_id=True, + metadata={"reject_reasons": ["drift_skip", "config_drift", "silent_skip"]}, + ) + + +def auth_ok() -> EvidencePolicy: + return EvidencePolicy( + name="auth_ok", + required_source="habitat_trusted_ledger", + required_status="ok", + require_run_id=True, + metadata={"reject_status_prefixes": ["auth_", "401", "403"]}, + ) + + +def recent_action(max_age_seconds: int = 3600) -> EvidencePolicy: + return EvidencePolicy( + name="recent_action", + required_source="habitat_trusted_ledger", + require_run_id=True, + max_age_seconds=max_age_seconds, + ) + + +PRESETS = { + "trusted_ledger_with_run": trusted_ledger_with_run, + "no_drift_skip": no_drift_skip, + "auth_ok": auth_ok, + "recent_action": recent_action, +} From 830b503d7a6c96e0e5780df1ccfef70a35226c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:22 +0200 Subject: [PATCH 15/99] Integrate Habitat runtime: add habitat/proof.py --- habitat/proof.py | 94 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 habitat/proof.py diff --git a/habitat/proof.py b/habitat/proof.py new file mode 100644 index 0000000..2b7263c --- /dev/null +++ b/habitat/proof.py @@ -0,0 +1,94 @@ +"""Portable, deterministic proof bundles for Habitat claim decisions. + +A proof bundle is deliberately small: it carries the claim, verdict, evidence, +relevant trusted actions, ledger-integrity result, and a content hash. It does +not pretend to be a cryptographic attestation of the outside world; it proves +what Habitat observed and whether its own evidence chain was intact. +""" +from __future__ import annotations + +import hashlib +import json +from typing import Any + +from .schema import utcnow +from .proof_verify import MAX_PROOF_ACTIONS, MAX_PROOF_BYTES + + +def _canonical(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False) + + +def _action_dict(action: Any) -> dict[str, Any]: + return { + "id": action.id, + "habitat_id": action.habitat_id, + "job_id": action.job_id, + "timestamp": action.timestamp.isoformat(), + "actor": action.actor, + "action": action.action, + "status": action.status, + "details": action.details, + "run_id": action.run_id, + } + + +def build_proof(store: Any, claim_id: str) -> dict[str, Any]: + """Build a portable proof bundle for one stored claim. + + The bundle is deterministic except for ``generated_at``. The hash covers + every field except that timestamp and the optional signature, so the same + observed evidence produces the same content digest whether or not a + signature is subsequently attached. + """ + claim = store.get_claim(claim_id) + if claim is None: + raise KeyError(f"Unknown claim: {claim_id}") + + integrity_ok = store.verify_action_integrity() + if claim.run_id: + actions = store.actions_for_run(claim.run_id) + else: + actions = store.matching_actions(claim.habitat_id, claim.job_id, claim.action) + if len(actions) > MAX_PROOF_ACTIONS: + raise ValueError(f"proof bundle exceeds maximum of {MAX_PROOF_ACTIONS} actions") + actions = sorted(actions, key=lambda a: (a.timestamp, a.id)) + + bundle: dict[str, Any] = { + "proof_version": "1", + "generated_at": utcnow().isoformat(), + "claim": { + "id": claim.id, + "habitat_id": claim.habitat_id, + "job_id": claim.job_id, + "claim": claim.claim, + "action": claim.action, + "expected_status": claim.expected_status, + "created_at": claim.created_at.isoformat(), + "verified_at": claim.verified_at.isoformat() if claim.verified_at else None, + "status": claim.status, + "evidence": claim.evidence, + "run_id": claim.run_id, + }, + "ledger": { + "integrity": "intact" if integrity_ok else "failed", + "actions": [_action_dict(a) for a in actions], + }, + } + digest_input = dict(bundle) + digest_input.pop("generated_at") + bundle["content_sha256"] = hashlib.sha256(_canonical(digest_input).encode()).hexdigest() + if len(_canonical(bundle).encode("utf-8")) > MAX_PROOF_BYTES: + raise ValueError(f"proof bundle exceeds maximum size of {MAX_PROOF_BYTES} bytes") + return bundle + + +def export_proof(store: Any, claim_id: str, path: str) -> dict[str, Any]: + """Write a proof bundle as UTF-8 JSON and return the bundle.""" + bundle = build_proof(store, claim_id) + raw = json.dumps(bundle, indent=2, ensure_ascii=False).encode("utf-8") + b"\n" + if len(raw) > MAX_PROOF_BYTES: + raise ValueError(f"proof bundle exceeds maximum size of {MAX_PROOF_BYTES} bytes") + with open(path, "wb") as handle: + handle.write(raw) + return bundle From 299d2b568f93342818b6635f1760c63d94fcc5c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:25 +0200 Subject: [PATCH 16/99] Integrate Habitat runtime: add habitat/proof_card.py --- habitat/proof_card.py | 49 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 habitat/proof_card.py diff --git a/habitat/proof_card.py b/habitat/proof_card.py new file mode 100644 index 0000000..903e970 --- /dev/null +++ b/habitat/proof_card.py @@ -0,0 +1,49 @@ +"""Human-readable proof cards for PRs, tickets, and Slack.""" +from __future__ import annotations + +from typing import Any + + +def render_proof_card(proof: dict[str, Any], result: dict[str, Any] | None = None, proof_filename: str = "proof.json") -> str: + """Return a short markdown card that can be pasted into a PR or message. + + `proof_filename` must be the file actually written for this proof, so + the embedded verify command never references an unwritten file (UX-06). + """ + claim = proof.get("claim") or {} + ledger = proof.get("ledger") or {} + evidence = claim.get("evidence") if isinstance(claim.get("evidence"), dict) else {} + result = result or {} + + status = str(claim.get("status") or result.get("verdict") or "unknown").upper() + claim_text = str(claim.get("claim") or "") + run_id = claim.get("run_id") or "—" + job_id = claim.get("job_id") or "—" + action_id = evidence.get("action_id") or "—" + source = evidence.get("source") or "—" + integrity = ledger.get("integrity") or "—" + digest = proof.get("content_sha256") or "—" + valid = result.get("valid") + if valid is None: + valid = status == "VERIFIED" + + lines = [ + "## Habitat Proof", + "", + f"**Claim:** {claim_text}", + f"**Status:** `{status}`", + f"**Run / Job:** `{run_id}` / `{job_id}`", + f"**Evidence:** {source} · action `{action_id}` · ledger `{integrity}`", + "", + "```bash", + f"python tools/verify_proof.py {proof_filename}", + f"# → {'VALID' if valid else 'INVALID'}", + "```", + "", + f"Digest: `{digest}`", + "", + "Assurance: structural · integrity · consistency established inside Habitat's evidence boundary. " + "External truth and publisher trust are not established by this card alone.", + "", + ] + return "\n".join(lines) From 13225a6286e0ecf45defb1b1e3cafa8baba94661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:27 +0200 Subject: [PATCH 17/99] Integrate Habitat runtime: add habitat/proof_sign.py --- habitat/proof_sign.py | 79 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 habitat/proof_sign.py diff --git a/habitat/proof_sign.py b/habitat/proof_sign.py new file mode 100644 index 0000000..4a3664c --- /dev/null +++ b/habitat/proof_sign.py @@ -0,0 +1,79 @@ +"""Optional Ed25519 signing for portable Habitat proof bundles. + +Signing is intentionally optional so the core runtime remains dependency-free. +Install the ``signing`` extra to use this module. Signatures authenticate the +proof bundle bytes; they do not prove that the underlying claim is true. +""" +from __future__ import annotations +import base64, copy, json +from typing import Any + + +def _canonical(value: dict[str, Any]) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode() + + +def _unsigned(bundle: dict[str, Any]) -> dict[str, Any]: + value = copy.deepcopy(bundle) + value.pop("signature", None) + return value + + +def _crypto(): + try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey + from cryptography.hazmat.primitives import serialization + return Ed25519PrivateKey, Ed25519PublicKey, serialization + except ImportError as exc: + raise RuntimeError("Proof signing requires the optional 'signing' dependency: pip install 'habitat[signing]'") from exc + + +def generate_keypair() -> tuple[str, str]: + Ed25519PrivateKey, _, serialization = _crypto() + private = Ed25519PrivateKey.generate() + private_raw = private.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()) + public_raw = private.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + return base64.b64encode(private_raw).decode(), base64.b64encode(public_raw).decode() + + +def sign_proof(bundle: dict[str, Any], private_key_b64: str, key_id: str | None = None, agent_id: str | None = None) -> dict[str, Any]: + """Return a signed copy of a proof bundle, optionally bound to an agent ID.""" + Ed25519PrivateKey, _, serialization = _crypto() + try: + private_raw = base64.b64decode(private_key_b64, validate=True) + except Exception as exc: + raise ValueError("Invalid base64 Ed25519 private key") from exc + if len(private_raw) != 32: + raise ValueError("Ed25519 private key must be 32 raw bytes") + signer = Ed25519PrivateKey.from_private_bytes(private_raw) + unsigned = _unsigned(bundle) + signature = signer.sign(_canonical(unsigned)) + public_raw = signer.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) + signed = copy.deepcopy(unsigned) + signed["signature"] = { + "algorithm": "Ed25519", + "key_id": key_id, + "agent_id": agent_id, + "public_key": base64.b64encode(public_raw).decode(), + "signature": base64.b64encode(signature).decode(), + } + return signed + + +def verify_signed_proof(bundle: dict[str, Any]) -> bool: + """Verify a signed proof's embedded public key and signature.""" + _, Ed25519PublicKey, _ = _crypto() + signature = bundle.get("signature") + if not isinstance(signature, dict) or signature.get("algorithm") != "Ed25519": + return False + try: + public_raw = base64.b64decode(signature["public_key"], validate=True) + sig_raw = base64.b64decode(signature["signature"], validate=True) + if len(public_raw) != 32 or len(sig_raw) != 64: + return False + Ed25519PublicKey.from_public_bytes(public_raw).verify(sig_raw, _canonical(_unsigned(bundle))) + return True + except Exception: + # cryptography raises InvalidSignature for a tampered bundle. Treat all + # malformed/invalid signatures as a clean verification failure. + return False From 77397f52c9b2c8dee8f5bcee7e2e29ae1be2876d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:33 +0200 Subject: [PATCH 18/99] Integrate Habitat runtime: add habitat/proof_ui.py --- habitat/proof_ui.py | 79 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 habitat/proof_ui.py diff --git a/habitat/proof_ui.py b/habitat/proof_ui.py new file mode 100644 index 0000000..d8289b5 --- /dev/null +++ b/habitat/proof_ui.py @@ -0,0 +1,79 @@ +"""Native proof inspection page for Habitat's supervision UI.""" +from __future__ import annotations + +from html import escape +from typing import Any + + +def _value(value: Any) -> str: + if value is None: + return "—" + return escape(str(value)) + + +def _assurance(proof: dict[str, Any], evidence: dict[str, Any]) -> tuple[tuple[str, str], ...]: + """Derive only assurance levels actually established by the proof schema.""" + claim = proof.get("claim") or {} + ledger = proof.get("ledger") or {} + signature = proof.get("signature") + verdict = claim.get("status") + ledger_integrity = ledger.get("integrity") + evidence_status = evidence.get("status") + + structural = "valid" if proof.get("proof_version") == "1" and claim and ledger else "unknown" + content = "valid" if isinstance(proof.get("content_sha256"), str) and len(proof["content_sha256"]) == 64 else "unknown" + consistent = "valid" if verdict in {"pending", "verified", "rejected", "inconclusive"} and evidence_status is not None else "unknown" + if verdict == "verified" and not evidence: + consistent = "invalid" + signature_state = "present" if signature else "not present" + publisher = "unknown" + external = "unknown" + return ( + ("Structural validity", structural), + ("Content integrity", content), + ("Internal consistency", consistent), + ("Signature", signature_state), + ("Publisher trust", publisher), + ("External truth", external), + ) + + +def render_proof_inspector(payload: dict[str, Any], title: str = "Habitat") -> str: + proof = payload.get("proof") or {} + claim = proof.get("claim") or {} + ledger = proof.get("ledger") or {} + signature = proof.get("signature") or {} + evidence = claim.get("evidence") or {} + if not isinstance(evidence, dict): + evidence = {} + + levels = _assurance(proof, evidence) + checks = "".join( + f'
{escape(label)}{escape(str(value).replace("_", " "))}
' + for label, value in levels + ) + + actions = proof.get("ledger", {}).get("actions") or [] + if not isinstance(actions, list): + actions = [] + evidence_action_id = evidence.get("action_id") + action = next( + (candidate for candidate in actions if isinstance(candidate, dict) and candidate.get("id") == evidence_action_id), + {}, + ) + signature_text = "present" if signature else "not present" + digest = proof.get("content_sha256") + claim_id = str(payload.get("claim_id", "")) + safe_claim_id = escape(claim_id) + body = f""" + +{escape(title)} — Proof {safe_claim_id} +
Habitat← Dashboard
+
Proof inspection · claim {safe_claim_id}

{_value(claim.get('status')).upper()} claim

Claim

{_value(claim.get('claim'))}
Expected status{_value(claim.get('expected_status'))}
Evidence source{_value(evidence.get('source'))}
Action ID{_value(evidence.get('action_id'))}
Run / job{_value(claim.get('run_id'))} / {_value(claim.get('job_id'))}
+

Ledger evidence

Integrity{_value(ledger.get('integrity'))}
Action status{_value(action.get('status'))}
Action ID{_value(action.get('id'))}
Action timestamp{_value(action.get('timestamp'))}
+

Proof digest

{_value(digest)}
+
""" + return body From 3ee55a9f707909451bb4d2b6114e89fbb3fe4ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:36 +0200 Subject: [PATCH 19/99] Integrate Habitat runtime: add habitat/proof_verify.py --- habitat/proof_verify.py | 293 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 habitat/proof_verify.py diff --git a/habitat/proof_verify.py b/habitat/proof_verify.py new file mode 100644 index 0000000..68dfe04 --- /dev/null +++ b/habitat/proof_verify.py @@ -0,0 +1,293 @@ +"""Dependency-light verification of exported Habitat proof bundles. + +The verifier separates structural validity, content integrity, internal +consistency, cryptographic signature validity, publisher trust, and external +truth. A valid bundle is never treated as proof that the outside world is true. + +CRITICAL: This verifier MUST catch all tampering. No exceptions. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import math +from datetime import datetime +from pathlib import Path +from typing import Any + +MAX_PROOF_BYTES = 16 * 1024 * 1024 +MAX_PROOF_ACTIONS = 10_000 + + +def _canonical(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False) + + +def _date_time(value: Any) -> bool: + if not isinstance(value, str): + return False + try: + parsed = datetime.fromisoformat(value[:-1] + "+00:00" if value.endswith("Z") else value) + return parsed.tzinfo is not None and parsed.utcoffset() is not None + except ValueError: + return False + + +def _finite_json_numbers(value: Any) -> bool: + if isinstance(value, float): + return math.isfinite(value) + if isinstance(value, dict): + return all(isinstance(k, str) and _finite_json_numbers(v) for k, v in value.items()) + if isinstance(value, list): + return all(_finite_json_numbers(v) for v in value) + return True + + +def _validate_structure(bundle: dict[str, Any], errors: list[str]) -> None: + allowed = {"proof_version", "generated_at", "claim", "ledger", "content_sha256", "signature"} + required = {"proof_version", "generated_at", "claim", "ledger", "content_sha256"} + unknown = sorted(set(bundle) - allowed) + missing = sorted(required - set(bundle)) + if unknown: + errors.append("unknown fields: " + ", ".join(unknown)) + if missing: + errors.append("missing fields: " + ", ".join(missing)) + if not _finite_json_numbers(bundle): + errors.append("non-finite JSON numbers are not allowed") + if bundle.get("proof_version") != "1": + errors.append("unsupported proof_version") + if not _date_time(bundle.get("generated_at")): + errors.append("generated_at must be an ISO-8601 date-time with timezone") + if not isinstance(bundle.get("content_sha256"), str) or len(bundle.get("content_sha256", "")) != 64 or any(c not in "0123456789abcdef" for c in bundle.get("content_sha256", "")): + errors.append("content_sha256 must be 64 lowercase hexadecimal characters") + + claim = bundle.get("claim") + ledger = bundle.get("ledger") + claim_required = {"id", "habitat_id", "job_id", "claim", "action", "expected_status", "created_at", "verified_at", "status", "evidence", "run_id"} + claim_allowed = claim_required + if not isinstance(claim, dict): + errors.append("claim must be an object") + else: + if set(claim) - claim_allowed: + errors.append("claim contains unknown fields") + if claim_required - set(claim): + errors.append("claim is missing required fields") + for field in ("id", "habitat_id", "claim", "expected_status", "status"): + if not isinstance(claim.get(field), str): + errors.append(f"claim.{field} must be a string") + if not isinstance(claim.get("evidence"), dict): + errors.append("claim.evidence must be an object") + for field in ("job_id", "action", "run_id"): + if claim.get(field) is not None and not isinstance(claim.get(field), str): + errors.append(f"claim.{field} must be a string or null") + if not _date_time(claim.get("created_at")): + errors.append("claim.created_at must be an ISO-8601 date-time with timezone") + if claim.get("verified_at") is not None and not _date_time(claim.get("verified_at")): + errors.append("claim.verified_at must be an ISO-8601 date-time with timezone or null") + + if not isinstance(ledger, dict): + errors.append("ledger must be an object") + else: + if set(ledger) != {"integrity", "actions"}: + errors.append("ledger must contain exactly integrity and actions") + if ledger.get("integrity") not in {"intact", "failed"}: + errors.append("ledger.integrity must be 'intact' or 'failed'") + actions = ledger.get("actions") + if not isinstance(actions, list): + errors.append("ledger.actions must be an array") + else: + if len(actions) > MAX_PROOF_ACTIONS: + errors.append(f"ledger.actions exceeds maximum of {MAX_PROOF_ACTIONS}") + required_action = {"id", "habitat_id", "job_id", "timestamp", "actor", "action", "status", "details", "run_id"} + for index, action in enumerate(actions[:MAX_PROOF_ACTIONS]): + if not isinstance(action, dict): + errors.append(f"ledger.actions[{index}] must be an object") + continue + if set(action) != required_action: + errors.append(f"ledger.actions[{index}] has invalid fields") + for field in ("id", "habitat_id", "actor", "action", "status"): + if not isinstance(action.get(field), str): + errors.append(f"ledger.actions[{index}].{field} must be a string") + if not isinstance(action.get("details"), dict): + errors.append(f"ledger.actions[{index}].details must be an object") + for field in ("job_id", "run_id"): + if action.get(field) is not None and not isinstance(action.get(field), str): + errors.append(f"ledger.actions[{index}].{field} must be a string or null") + if not _date_time(action.get("timestamp")): + errors.append(f"ledger.actions[{index}].timestamp must be an ISO-8601 date-time with timezone") + + if "signature" in bundle: + signature = bundle["signature"] + if not isinstance(signature, dict): + errors.append("signature must be an object") + else: + required_sig = {"algorithm", "key_id", "agent_id", "public_key", "signature"} + if set(signature) != required_sig: + errors.append("signature has invalid fields") + if signature.get("algorithm") != "Ed25519": + errors.append("signature.algorithm must be 'Ed25519'") + for field in ("key_id", "agent_id"): + if signature.get(field) is not None and not isinstance(signature.get(field), str): + errors.append(f"signature.{field} must be a string or null") + for field in ("public_key", "signature"): + if not isinstance(signature.get(field), str) or not signature.get(field): + errors.append(f"signature.{field} must be a non-empty string") + + +def _validate_relationships(claim: dict[str, Any], ledger: dict[str, Any], errors: list[str]) -> None: + actions = ledger.get("actions") + if not isinstance(actions, list): + return + claim_habitat = claim.get("habitat_id") + claim_job = claim.get("job_id") + claim_run = claim.get("run_id") + claim_action = claim.get("action") + expected_status = claim.get("expected_status") + for index, action in enumerate(actions): + if not isinstance(action, dict): + continue + if action.get("habitat_id") != claim_habitat: + errors.append(f"ledger.actions[{index}] habitat_id does not match claim") + if claim_job is not None and action.get("job_id") != claim_job: + errors.append(f"ledger.actions[{index}] job_id does not match claim") + if claim_run is not None and action.get("run_id") != claim_run: + errors.append(f"ledger.actions[{index}] run_id does not match claim") + evidence = claim.get("evidence") + if not isinstance(evidence, dict): + return + if claim.get("status") == "verified": + if not evidence: + errors.append("verified claim must include evidence") + else: + source = evidence.get("source") + if not isinstance(source, str) or not source: + errors.append("verified claim evidence must identify a source") + evidence_status = evidence.get("status") + if evidence_status != expected_status: + errors.append("verified claim evidence status does not match claim.expected_status") + if source == "habitat_trusted_ledger" and not evidence.get("action_id"): + errors.append("trusted-ledger verification must identify evidence.action_id") + evidence_action_id = evidence.get("action_id") + if evidence_action_id is None: + return + matched = next((a for a in actions if isinstance(a, dict) and a.get("id") == evidence_action_id), None) + if matched is None: + errors.append("claim evidence action_id is absent from ledger.actions") + return + if claim_action is not None and matched.get("action") != claim_action: + errors.append("claim evidence action does not match claim.action") + if expected_status is not None and matched.get("status") != expected_status: + errors.append("claim evidence action status does not match claim.expected_status") + if claim_run is not None and matched.get("run_id") != claim_run: + errors.append("claim evidence action run_id does not match claim.run_id") + + +def _verify_signature(bundle: dict[str, Any]) -> str: + """Report signature state without elevating an embedded key to publisher trust. + + A successful cryptographic check against the public key *carried in the + bundle* only proves the signature is well-formed for that key material. It + does not establish that the key is trusted by the verifier. Publisher trust + requires an explicit TrustRegistry (see habitat.trust). Therefore the + standalone / unconfigured path must never return "verified". + """ + signature = bundle.get("signature") + if signature is None: + return "absent" + try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + public_raw = base64.b64decode(signature["public_key"], validate=True) + sig_raw = base64.b64decode(signature["signature"], validate=True) + if len(public_raw) != 32 or len(sig_raw) != 64: + return "invalid" + unsigned = dict(bundle) + unsigned.pop("signature", None) + Ed25519PublicKey.from_public_bytes(public_raw).verify(sig_raw, _canonical(unsigned).encode("utf-8")) + # Crypto OK against the embedded key only. Without a TrustRegistry this + # remains present-unverified (publisher trust is a separate layer). + return "present-unverified" + except ImportError: + return "present-unverified" + except Exception: + return "invalid" + + +def verify_proof(bundle: dict[str, Any]) -> dict[str, Any]: + """Verify a decoded Habitat proof bundle and report assurance boundaries. + + CRITICAL: This function MUST catch all tampering. No exceptions. + """ + errors: list[str] = [] + structure_errors: list[str] = [] + _validate_structure(bundle, structure_errors) + errors.extend(structure_errors) + + # ALWAYS verify content_sha256 regardless of structure errors + expected = bundle.get("content_sha256") + actual = None + if isinstance(expected, str): + digest_input = dict(bundle) + digest_input.pop("generated_at", None) + digest_input.pop("content_sha256", None) + digest_input.pop("signature", None) + try: + actual = hashlib.sha256(_canonical(digest_input).encode()).hexdigest() + except (TypeError, ValueError): + actual = None + errors.append("content_sha256 computation failed") + if actual is not None and actual != expected: + errors.append("content_sha256 mismatch - proof has been tampered with") + + claim = bundle.get("claim") + ledger = bundle.get("ledger") + relationship_errors_before = len(errors) + if isinstance(claim, dict) and isinstance(ledger, dict): + _validate_relationships(claim, ledger, errors) + verdict = claim.get("status") if isinstance(claim, dict) else None + integrity = ledger.get("integrity") if isinstance(ledger, dict) else None + if verdict == "verified" and integrity != "intact": + errors.append("verified claim cannot have failed ledger integrity") + + # Signature verification - but don't let it override content integrity + signature_state = _verify_signature(bundle) if not any(e.startswith("signature") for e in structure_errors) else "invalid" + if signature_state == "invalid": + errors.append("signature verification failed") + + # A proof is valid ONLY if: no errors, content integrity, relationships valid + relationship_valid = len(errors) == relationship_errors_before + content_valid = isinstance(actual, str) and actual == expected + structural_valid = not structure_errors + valid = structural_valid and content_valid and relationship_valid + + return { + "valid": valid, + "content_sha256": actual, + "verdict": verdict, + "ledger_integrity": integrity, + "errors": errors, + "scope": "bundle-integrity-and-internal-consistency", + "authenticity": "cryptographic-signature" if signature_state == "verified" else "not-established", + "assurance": { + "structural_validity": structural_valid, + "content_integrity": content_valid, + "internal_consistency": relationship_valid and not (verdict == "verified" and integrity != "intact"), + "signature": signature_state, + "publisher_trust": "not-assessed", + "external_truth": "not-established", + }, + } + + +def verify_file(path: str | Path) -> dict[str, Any]: + path = Path(path) + if path.stat().st_size > MAX_PROOF_BYTES: + return {"valid": False, "errors": [f"proof bundle exceeds maximum size of {MAX_PROOF_BYTES} bytes"]} + try: + with path.open("r", encoding="utf-8") as handle: + value = json.load(handle, parse_constant=lambda value: (_ for _ in ()).throw(ValueError("invalid_json_constant"))) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + return {"valid": False, "errors": [str(exc)]} + if not isinstance(value, dict): + return {"valid": False, "errors": ["proof bundle must be a JSON object"]} + return verify_proof(value) From 7a99ef19ad4495bdc848ea7dd118bfcfbb924f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:41 +0200 Subject: [PATCH 20/99] Integrate Habitat runtime: add habitat/runtime.py --- habitat/runtime.py | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 habitat/runtime.py diff --git a/habitat/runtime.py b/habitat/runtime.py new file mode 100644 index 0000000..4ad4b5f --- /dev/null +++ b/habitat/runtime.py @@ -0,0 +1,47 @@ +"""Job execution, scheduling and trusted truth recording.""" +from __future__ import annotations +import time,uuid,re +from .schema import Job,Action,utcnow +from .store import Store +from .signals import detect,reconcile,health_status +from .adapters import JobAdapter,ShellAdapter + +def _interval_seconds(schedule): + if not schedule:return None + m=re.fullmatch(r"every\s+(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs)",schedule.strip().lower()) + if not m:return None + n=int(m.group(1)); return n*({"s":1,"sec":1,"secs":1,"m":60,"min":60,"mins":60,"h":3600,"hr":3600,"hrs":3600}[m.group(2)]) + +def due(j:Job,now=None): + if not j.enabled:return False + interval=_interval_seconds(j.schedule) + if interval is None:return j.last_run_at is None + return j.last_run_at is None or (now or utcnow())-j.last_run_at >= __import__('datetime').timedelta(seconds=interval) + +def run_once(store:Store,adapter:JobAdapter|None=None,jobs_filter=None): + adapter=adapter or ShellAdapter();habitat=store.habitat();run_id=str(uuid.uuid4());selected=[j for j in store.jobs() if (jobs_filter is None or j.id in jobs_filter)] + for j in selected: + if not j.enabled: + j.last_status="skipped";store.save_job(j);store.save_action(Action(str(uuid.uuid4()),habitat.id,utcnow(),"system","skip_job","ok",j.id,{"reason":"disabled"},run_id));continue + j.last_run_at=utcnow() + if not j.command: + j.last_status="failed";j.failure_streak+=1;j.last_error="No command configured";store.save_action(Action(str(uuid.uuid4()),habitat.id,utcnow(),"system","run_job","failed",j.id,{"error":j.last_error},run_id));store.save_job(j);continue + r=adapter.execute(j.command) + if r.ok:j.last_status="ok";j.failure_streak=0;j.last_error=None;store.save_action(Action(str(uuid.uuid4()),habitat.id,utcnow(),"agent","run_job","ok",j.id,{"stdout":r.stdout},run_id)) + else:j.last_status="failed";j.failure_streak+=1;j.last_error=(r.stderr or r.stdout or f"exit {r.exit_code}").strip()[-2000:];store.save_action(Action(str(uuid.uuid4()),habitat.id,utcnow(),"agent","run_job","failed",j.id,{"error":j.last_error,"exit_code":r.exit_code},run_id)) + store.save_job(j) + jobs=store.jobs(); existing=store.signals();desired=detect(habitat,jobs);keys={(s.job_id,s.code) for s in existing if s.resolved_at is None} + for signal in desired: + if (signal.job_id,signal.code) not in keys:store.save_signal(signal) + reconcile(existing,desired) + for s in existing: + if s.resolved_at:store.save_signal(s) + habitat.status=health_status(jobs,store.signals());habitat.updated_at=utcnow();store.save_habitat(habitat);return jobs + +def run_loop(store,adapter=None,interval=10,stop_after=None): + started=time.monotonic() + while True: + now=utcnow();due_ids=[j.id for j in store.jobs() if due(j,now)] + if due_ids:run_once(store,adapter,due_ids) + if stop_after is not None and time.monotonic()-started>=stop_after:break + time.sleep(max(0.1,interval)) From ddf1b5f1753789eaadbf5b2f62967bff4ff046db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:44 +0200 Subject: [PATCH 21/99] Integrate Habitat runtime: add habitat/schema.py --- habitat/schema.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 habitat/schema.py diff --git a/habitat/schema.py b/habitat/schema.py new file mode 100644 index 0000000..4f3c13b --- /dev/null +++ b/habitat/schema.py @@ -0,0 +1,37 @@ +"""Typed domain objects for Habitat.""" +from __future__ import annotations +from dataclasses import dataclass, field, asdict +from datetime import datetime, timezone +from typing import Any, Optional, Literal + +HabitatStatus = Literal["healthy", "degraded", "needs_attention", "disabled"] +Severity = Literal["critical", "warning", "info"] +SignalType = Literal["anomaly", "info", "audit"] +JobStatus = Literal["ok", "failed", "skipped", "running"] +Actor = Literal["agent", "human", "system"] +ActionStatus = Literal["ok", "failed", "rejected", "approved"] + +def utcnow() -> datetime: return datetime.now(timezone.utc) + +@dataclass +class Habitat: + id: str; name: str; model: str = "unknown"; created_at: datetime = field(default_factory=utcnow); updated_at: datetime = field(default_factory=utcnow); status: HabitatStatus = "healthy" + +@dataclass +class Job: + id: str; habitat_id: str; name: str; schedule: Optional[str] = None; enabled: bool = True; command: Optional[str] = None; last_run_at: Optional[datetime] = None; last_status: Optional[JobStatus] = None; failure_streak: int = 0; last_error: Optional[str] = None + +@dataclass +class Signal: + id: str; habitat_id: str; type: SignalType; severity: Severity; code: str; message: str; job_id: Optional[str] = None; detected_at: datetime = field(default_factory=utcnow); resolved_at: Optional[datetime] = None; data: dict[str, Any] = field(default_factory=dict) + +@dataclass +class Action: + id: str; habitat_id: str; timestamp: datetime; actor: Actor; action: str; status: ActionStatus; job_id: Optional[str] = None; details: dict[str, Any] = field(default_factory=dict); run_id: Optional[str] = None + +def to_dict(obj: Any) -> dict[str, Any]: return _serialize(asdict(obj)) +def _serialize(value: Any) -> Any: + if isinstance(value, datetime): return value.isoformat() + if isinstance(value, dict): return {k: _serialize(v) for k,v in value.items()} + if isinstance(value, list): return [_serialize(v) for v in value] + return value From 01b8c4489edb8066e19646c3e56d5542bee036e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:50 +0200 Subject: [PATCH 22/99] Integrate Habitat runtime: add habitat/server.py --- habitat/server.py | 322 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 habitat/server.py diff --git a/habitat/server.py b/habitat/server.py new file mode 100644 index 0000000..f336a94 --- /dev/null +++ b/habitat/server.py @@ -0,0 +1,322 @@ +"""Local-first Habitat HTTP API. + +Loopback listeners may be used without an HTTP auth header. Non-loopback +listeners require the configured server secret as a bearer credential for +every request. Remote deployments should additionally use TLS at the network +boundary because bearer credentials and event secrets are transport secrets. +""" +from __future__ import annotations + +from collections import defaultdict +import hmac +import ipaddress +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse + +from .events import ingest_event, MAX_EVENT_BYTES +from .store import Store +from .generate import render_dashboard, render_landing +from .proof_ui import render_proof_inspector +from .verify_api import proof_status, verify_and_prove + +MAX_HTTP_CONNECTIONS = 64 + + +class _AuthRateLimiter: + """Bound repeated remote authentication failures without affecting localhost.""" + def __init__(self, max_failures=5, window_seconds=60, max_keys=4096): + self.max_failures = max_failures + self.window_seconds = window_seconds + self.max_keys = max_keys + self._failures = defaultdict(list) + self._lock = threading.Lock() + + def _prune(self, key, now): + cutoff = now - self.window_seconds + entries = [stamp for stamp in self._failures.get(key, []) if stamp > cutoff] + if entries: + self._failures[key] = entries + else: + self._failures.pop(key, None) + return entries + + def blocked(self, key, now=None): + now = time.monotonic() if now is None else now + with self._lock: + return len(self._prune(key, now)) >= self.max_failures + + def record_failure(self, key, now=None): + now = time.monotonic() if now is None else now + with self._lock: + entries = self._prune(key, now) + entries.append(now) + self._failures[key] = entries + if len(self._failures) > self.max_keys: + oldest_key = min(self._failures, key=lambda candidate: self._failures[candidate][-1]) + if oldest_key != key: + self._failures.pop(oldest_key, None) + return len(entries) >= self.max_failures + + def clear(self, key): + with self._lock: + self._failures.pop(key, None) + + +class HabitatHTTPServer(ThreadingHTTPServer): + """Threaded HTTP server with a hard cap on active request threads.""" + allow_reuse_address = True + daemon_threads = True + + def __init__(self, server_address, handler_cls, max_connections=MAX_HTTP_CONNECTIONS): + if max_connections <= 0: + raise ValueError("max_connections must be positive") + self._connection_slots = threading.BoundedSemaphore(max_connections) + super().__init__(server_address, handler_cls) + + def process_request(self, request, client_address): + if not self._connection_slots.acquire(blocking=False): + request.close() + return + try: + super().process_request(request, client_address) + except Exception: + self._connection_slots.release() + raise + + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self._connection_slots.release() + + +def _is_loopback_host(host: str) -> bool: + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return host.lower() in {"localhost", "ip6-localhost"} + + +def make_handler(db_path, secret, require_signature=True, protect_remote=True): + limiter = _AuthRateLimiter() + + class Handler(BaseHTTPRequestHandler): + server_version = "Habitat/1.3" + protocol_version = "HTTP/1.1" + + def setup(self): + super().setup() + self.connection.settimeout(10) + + def _remote_protected(self) -> bool: + if not protect_remote: + return False + bound = getattr(self.server, "server_address", ("127.0.0.1", 0))[0] + return not _is_loopback_host(str(bound)) + + def _client_key(self, kind): + peer = getattr(self, "client_address", ("unknown", 0)) + return kind, str(peer[0]) + + def _auth_throttled(self, kind): + return limiter.blocked(self._client_key(kind)) + + def _auth_failed(self, kind): + return limiter.record_failure(self._client_key(kind)) + + def _auth_succeeded(self, kind): + limiter.clear(self._client_key(kind)) + + def _send_rate_limited(self): + self._send(429, {"error": "authentication_rate_limited"}, headers={"Retry-After": "60"}) + + def _authorized_read(self): + if not self._remote_protected(): + return True + if self._auth_throttled("server"): + return None + supplied = self.headers.get("Authorization", "") + if not secret or not supplied.startswith("Bearer "): + self._auth_failed("server") + return False + valid = hmac.compare_digest(supplied[7:].strip(), secret) + if not valid: + self._auth_failed("server") + return False + self._auth_succeeded("server") + return True + + def _send(self, code, payload, headers=None): + raw = json.dumps(payload, default=str).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(raw))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + for name, value in (headers or {}).items(): + self.send_header(name, value) + self.end_headers() + self.wfile.write(raw) + + def _send_forbidden(self): + self._send(401, {"error": "authentication_required"}) + + def do_GET(self): + authorized = self._authorized_read() + if authorized is None: + self._send_rate_limited() + return + if not authorized: + self._send_forbidden() + return + path = urlparse(self.path).path + s = Store(db_path) + try: + if path == "/": + html = render_landing(s.habitat().name) + raw = html.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(raw))) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; connect-src 'self'") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.end_headers() + self.wfile.write(raw) + elif path == "/dashboard": + html = render_dashboard(s.habitat(), s.jobs(), s.signals(), s.actions(), s.habitat().name, s.claims()) + raw = html.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(raw))) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.end_headers() + self.wfile.write(raw) + elif path.startswith("/dashboard/claims/"): + claim_id = path[len("/dashboard/claims/"):].strip("/") + if not claim_id or "/" in claim_id: + self._send(400, {"error": "invalid_claim_id"}) + else: + html = render_proof_inspector(proof_status(s, claim_id), s.habitat().name) + raw = html.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(raw))) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.end_headers() + self.wfile.write(raw) + elif path == "/v1/status": + h = s.habitat() + self._send(200, {"id": h.id, "name": h.name, "status": h.status, "model": h.model, "jobs": len(s.jobs()), "active_signals": len(s.active_signals()), "agents": len(s.agents())}) + elif path == "/v1/claims": + self._send(200, [c.__dict__ for c in s.claims()]) + elif path.startswith("/v1/claims/") and path.endswith("/proof"): + claim_id = path[len("/v1/claims/"):-len("/proof")].strip("/") + if not claim_id: + self._send(400, {"error": "missing_claim_id"}) + else: + self._send(200, proof_status(s, claim_id)) + elif path.startswith("/v1/claims/") and path.endswith("/verify"): + claim_id = path[len("/v1/claims/"):-len("/verify")].strip("/") + if not claim_id: + self._send(400, {"error": "missing_claim_id"}) + else: + self._send(200, verify_and_prove(s, claim_id)) + elif path == "/v1/signals": + self._send(200, [x.__dict__ for x in s.active_signals()]) + elif path == "/v1/events": + self._send(200, s.events()) + elif path == "/v1/actions": + self._send(200, [x.__dict__ for x in s.actions()]) + elif path == "/v1/agents": + self._send(200, s.agents()) + elif path == "/healthz": + self._send(200, {"ok": True}) + else: + self._send(404, {"error": "not_found"}) + except KeyError: + self._send(404, {"error": "claim_not_found"}) + except RuntimeError as exc: + self._send(503, {"error": str(exc)}) + finally: + s.close() + + def do_POST(self): + if urlparse(self.path).path != "/v1/events": + self._send(404, {"error": "not_found"}) + return + if self._remote_protected(): + authorized = self._authorized_read() + if authorized is None: + self._send_rate_limited() + return + if not authorized: + self._send_forbidden() + return + if self._remote_protected() and self._auth_throttled("agent"): + self._send_rate_limited() + return + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + self._send(400, {"error": "invalid_content_length"}) + return + if length <= 0 or length > MAX_EVENT_BYTES: + self._send(413, {"error": "event_too_large"}) + return + body = self.rfile.read(length) + if len(body) != length: + self._send(400, {"error": "incomplete_request_body"}) + return + sig = self.headers.get("X-Habitat-Signature") + agent_id = self.headers.get("X-Habitat-Agent") + agent_secret = self.headers.get("X-Habitat-Agent-Secret") + s = Store(db_path) + try: + result = ingest_event(s, body, sig, secret, require_signature, agent_id, agent_secret) + if self._remote_protected() and agent_secret: + self._auth_succeeded("agent") + self._send(200 if result.get("duplicate") else 202, result) + except PermissionError as exc: + if self._remote_protected() and agent_secret: + self._auth_failed("agent") + if self._auth_throttled("agent"): + self._send_rate_limited() + return + self._send(401, {"error": str(exc)}) + except ValueError as exc: + self._send(400, {"error": str(exc)}) + except RuntimeError as exc: + self._send(503, {"error": str(exc)}) + finally: + s.close() + + def log_message(self, *args): + return + + return Handler + + +def serve(db_path, host="127.0.0.1", port=8787, secret=None, require_signature=True): + if not _is_loopback_host(host) and not secret: + raise ValueError("non-loopback listeners require a server secret") + + httpd = HabitatHTTPServer((host, port), make_handler(db_path, secret, require_signature, protect_remote=True)) + print(f"Habitat API listening on http://{host}:{port}") + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + finally: + httpd.server_close() From 587ef93f1e9186aa933cc8f1f5aee0b56d612f0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:52 +0200 Subject: [PATCH 23/99] Integrate Habitat runtime: add habitat/signals.py --- habitat/signals.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 habitat/signals.py diff --git a/habitat/signals.py b/habitat/signals.py new file mode 100644 index 0000000..ffa8d9a --- /dev/null +++ b/habitat/signals.py @@ -0,0 +1,28 @@ +"""High-signal anomaly derivation and lifecycle reconciliation.""" +from __future__ import annotations +from .schema import Habitat, Job, Signal, utcnow +import uuid + +def detect(habitat: Habitat, jobs: list[Job]) -> list[Signal]: + out=[]; now=utcnow() + for j in jobs: + if j.failure_streak >= 3: out.append(Signal(str(uuid.uuid4()),habitat.id,"anomaly","critical","failure_streak",f"{j.name} has failed {j.failure_streak} times in a row",j.id,now,None,{"failure_streak":j.failure_streak})) + if not j.enabled and j.schedule: out.append(Signal(str(uuid.uuid4()),habitat.id,"anomaly","warning","disabled_but_expected",f"{j.name} is disabled but has a schedule",j.id,now,None,{"schedule":j.schedule})) + if j.last_error and "auth" in j.last_error.lower(): out.append(Signal(str(uuid.uuid4()),habitat.id,"anomaly","critical","auth_failure",f"{j.name} reported an authentication failure",j.id,now,None,{"error":j.last_error})) + return out + +def reconcile(existing: list[Signal], desired: list[Signal]) -> list[Signal]: + """Return desired signals plus existing signals that should be resolved.""" + active={(s.job_id,s.code):s for s in existing if s.resolved_at is None} + desired_keys={(s.job_id,s.code) for s in desired} + now=utcnow() + for key,s in active.items(): + if key not in desired_keys: s.resolved_at=now + return desired + +def health_status(jobs: list[Job], signals: list[Signal]) -> str: + active=[s for s in signals if s.resolved_at is None] + if any(s.severity=="critical" for s in active): return "needs_attention" + if any(s.severity=="warning" for s in active): return "degraded" + if jobs and all(not j.enabled for j in jobs): return "disabled" + return "healthy" From 6c390affd9ec397965568512ba679fd29a29f74a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:56 +0200 Subject: [PATCH 24/99] Integrate Habitat runtime: add habitat/store.py --- habitat/store.py | 302 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 habitat/store.py diff --git a/habitat/store.py b/habitat/store.py new file mode 100644 index 0000000..16ba92d --- /dev/null +++ b/habitat/store.py @@ -0,0 +1,302 @@ +"""SQLite persistence, migrations, audit and agent identity.""" +from __future__ import annotations + +import hashlib +import hmac +import json +import secrets +import sqlite3 +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path +from typing import Any + +from .claims import Claim +from .schema import Action, Habitat, Job, Signal + +SCHEMA_VERSION = 3 +PBKDF2_ITERATIONS = 600_000 +SCHEMA = """ +CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS habitat (id TEXT PRIMARY KEY, name TEXT NOT NULL, model TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, status TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS job (id TEXT PRIMARY KEY, habitat_id TEXT NOT NULL, name TEXT NOT NULL, schedule TEXT, enabled INTEGER NOT NULL, command TEXT, last_run_at TEXT, last_status TEXT, failure_streak INTEGER NOT NULL, last_error TEXT); +CREATE TABLE IF NOT EXISTS signal (id TEXT PRIMARY KEY, habitat_id TEXT NOT NULL, job_id TEXT, type TEXT NOT NULL, severity TEXT NOT NULL, code TEXT NOT NULL, message TEXT NOT NULL, detected_at TEXT NOT NULL, resolved_at TEXT, data TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS action (id TEXT PRIMARY KEY, habitat_id TEXT NOT NULL, job_id TEXT, timestamp TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, status TEXT NOT NULL, details TEXT NOT NULL, run_id TEXT); +CREATE TABLE IF NOT EXISTS claim (id TEXT PRIMARY KEY, habitat_id TEXT NOT NULL, job_id TEXT, claim TEXT NOT NULL, action TEXT, expected_status TEXT NOT NULL, created_at TEXT NOT NULL, verified_at TEXT, status TEXT NOT NULL, evidence TEXT NOT NULL, run_id TEXT); +CREATE TABLE IF NOT EXISTS event (id TEXT PRIMARY KEY, habitat_id TEXT NOT NULL, type TEXT NOT NULL, received_at TEXT NOT NULL, signature_valid INTEGER NOT NULL, payload TEXT NOT NULL, correlation_id TEXT, agent_id TEXT); +CREATE TABLE IF NOT EXISTS agent (id TEXT PRIMARY KEY, habitat_id TEXT NOT NULL, name TEXT NOT NULL, enabled INTEGER NOT NULL, permissions TEXT NOT NULL, secret_hash TEXT, created_at TEXT NOT NULL, last_seen_at TEXT); +CREATE INDEX IF NOT EXISTS idx_action_run ON action(run_id); +CREATE INDEX IF NOT EXISTS idx_action_job_action_run ON action(job_id, action, run_id, status); +CREATE TABLE IF NOT EXISTS action_integrity (seq INTEGER PRIMARY KEY AUTOINCREMENT, action_id TEXT NOT NULL UNIQUE, prev_hash TEXT, hash TEXT NOT NULL); +CREATE INDEX IF NOT EXISTS idx_claim_run ON claim(run_id); +CREATE INDEX IF NOT EXISTS idx_event_received ON event(received_at); +""" + + +def _dt(value): + return datetime.fromisoformat(value) if value else None + + +def _json_object(value: str, field: str) -> dict[str, Any]: + """Decode persisted JSON and fail with a useful storage error.""" + try: + decoded = json.loads(value) + except (TypeError, json.JSONDecodeError) as exc: + raise RuntimeError(f"corrupt_json:{field}") from exc + if not isinstance(decoded, dict): + raise RuntimeError(f"invalid_json_object:{field}") + return decoded + + +class Store: + def __init__(self, path): + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self.conn = sqlite3.connect(self.path, timeout=10) + self.conn.row_factory = sqlite3.Row + self.conn.execute("PRAGMA foreign_keys=ON") + self.conn.execute("PRAGMA journal_mode=WAL") + self.conn.execute("PRAGMA busy_timeout=10000") + self._transaction_depth = 0 + self.conn.executescript(SCHEMA) + self._migrate() + self.conn.commit() + + @contextmanager + def transaction(self, immediate=False): + """Run a group of writes atomically; nested calls reuse the transaction.""" + outer = self._transaction_depth == 0 + if outer: + self.conn.execute("BEGIN IMMEDIATE" if immediate else "BEGIN") + self._transaction_depth += 1 + try: + yield self + if outer: + self.conn.commit() + except Exception: + if outer: + self.conn.rollback() + raise + finally: + self._transaction_depth -= 1 + + def _migrate(self): + cols = {r[1] for r in self.conn.execute("PRAGMA table_info(event)")} + if "agent_id" not in cols: + self.conn.execute("ALTER TABLE event ADD COLUMN agent_id TEXT") + self.conn.execute("INSERT OR IGNORE INTO schema_meta(key,value) VALUES('version','1')") + self.conn.execute("UPDATE schema_meta SET value=? WHERE key='version'", (str(SCHEMA_VERSION),)) + marker = self.conn.execute("SELECT value FROM schema_meta WHERE key='action_integrity_initialized'").fetchone() + if marker is None: + self._bootstrap_action_integrity() + self.conn.execute("INSERT INTO schema_meta(key,value) VALUES('action_integrity_initialized','1')") + + def close(self): + self.conn.close() + + @staticmethod + def _action_payload(a): + return {"id": a.id, "habitat_id": a.habitat_id, "job_id": a.job_id, "timestamp": a.timestamp.isoformat(), "actor": a.actor, "action": a.action, "status": a.status, "details": a.details, "run_id": a.run_id} + + @classmethod + def _action_hash(cls, a, prev_hash): + payload = {"prev_hash": prev_hash, "action": cls._action_payload(a)} + return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest() + + def _bootstrap_action_integrity(self): + if self.conn.execute("SELECT 1 FROM action_integrity LIMIT 1").fetchone(): + return + rows = self.conn.execute("SELECT * FROM action ORDER BY timestamp,id").fetchall() + prev = None + for r in rows: + a = Action(r["id"], r["habitat_id"], _dt(r["timestamp"]), r["actor"], r["action"], r["status"], r["job_id"], _json_object(r["details"], "action.details"), r["run_id"]) + digest = self._action_hash(a, prev) + self.conn.execute("INSERT INTO action_integrity(action_id,prev_hash,hash) VALUES(?,?,?)", (a.id, prev, digest)) + prev = digest + + def close_and_commit(self): + self.conn.commit() + self.close() + + def save_habitat(self, h): + self.conn.execute("INSERT OR REPLACE INTO habitat VALUES (?,?,?,?,?,?)", (h.id, h.name, h.model, h.created_at.isoformat(), h.updated_at.isoformat(), h.status)) + if self._transaction_depth == 0: + self.conn.commit() + + def save_job(self, j): + self.conn.execute("INSERT OR REPLACE INTO job VALUES (?,?,?,?,?,?,?,?,?,?)", (j.id, j.habitat_id, j.name, j.schedule, int(j.enabled), j.command, j.last_run_at.isoformat() if j.last_run_at else None, j.last_status, j.failure_streak, j.last_error)) + if self._transaction_depth == 0: + self.conn.commit() + + def save_signal(self, s): + self.conn.execute("INSERT OR REPLACE INTO signal VALUES (?,?,?,?,?,?,?,?,?,?)", (s.id, s.habitat_id, s.job_id, s.type, s.severity, s.code, s.message, s.detected_at.isoformat(), s.resolved_at.isoformat() if s.resolved_at else None, json.dumps(s.data))) + if self._transaction_depth == 0: + self.conn.commit() + + def save_action(self, a): + outer = self._transaction_depth == 0 + if outer: + self.conn.execute("BEGIN IMMEDIATE") + try: + prev = self.conn.execute("SELECT hash FROM action_integrity ORDER BY seq DESC LIMIT 1").fetchone() + prev_hash = prev[0] if prev else None + digest = self._action_hash(a, prev_hash) + self.conn.execute("INSERT INTO action VALUES (?,?,?,?,?,?,?,?,?)", (a.id, a.habitat_id, a.job_id, a.timestamp.isoformat(), a.actor, a.action, a.status, json.dumps(a.details), a.run_id)) + self.conn.execute("INSERT INTO action_integrity(action_id,prev_hash,hash) VALUES(?,?,?)", (a.id, prev_hash, digest)) + if outer: + self.conn.commit() + except Exception: + if outer: + self.conn.rollback() + raise + + def habitat(self): + r = self.conn.execute("SELECT * FROM habitat LIMIT 1").fetchone() + if not r: + raise RuntimeError("No Habitat initialized") + return Habitat(r["id"], r["name"], r["model"], _dt(r["created_at"]), _dt(r["updated_at"]), r["status"]) + + def jobs(self): + return [Job(r["id"], r["habitat_id"], r["name"], r["schedule"], bool(r["enabled"]), r["command"], _dt(r["last_run_at"]), r["last_status"], r["failure_streak"], r["last_error"]) for r in self.conn.execute("SELECT * FROM job ORDER BY name")] + + def signals(self): + return [Signal(r["id"], r["habitat_id"], r["type"], r["severity"], r["code"], r["message"], r["job_id"], _dt(r["detected_at"]), _dt(r["resolved_at"]), _json_object(r["data"], "signal.data")) for r in self.conn.execute("SELECT * FROM signal ORDER BY detected_at DESC")] + + def actions(self, limit=50): + return [Action(r["id"], r["habitat_id"], _dt(r["timestamp"]), r["actor"], r["action"], r["status"], r["job_id"], _json_object(r["details"], "action.details"), r["run_id"]) for r in self.conn.execute("SELECT * FROM action ORDER BY timestamp DESC LIMIT ?", (limit,))] + + def actions_for_run(self, run_id): + if not run_id: + return [] + return [Action(r["id"], r["habitat_id"], _dt(r["timestamp"]), r["actor"], r["action"], r["status"], r["job_id"], _json_object(r["details"], "action.details"), r["run_id"]) for r in self.conn.execute("SELECT * FROM action WHERE run_id=? ORDER BY timestamp,id", (run_id,))] + + def matching_actions(self, habitat_id, job_id=None, action=None, run_id=None, limit=5000): + clauses = ["habitat_id=?"] + params: list[Any] = [habitat_id] + if job_id is not None: + clauses.append("job_id=?"); params.append(job_id) + if action is not None: + clauses.append("action=?"); params.append(action) + if run_id is not None: + clauses.append("run_id=?"); params.append(run_id) + sql = "SELECT * FROM action WHERE " + " AND ".join(clauses) + " ORDER BY timestamp,id LIMIT ?" + params.append(limit) + return [Action(r["id"], r["habitat_id"], _dt(r["timestamp"]), r["actor"], r["action"], r["status"], r["job_id"], _json_object(r["details"], "action.details"), r["run_id"]) for r in self.conn.execute(sql, params)] + + def get_claim(self, claim_id): + r = self.conn.execute("SELECT * FROM claim WHERE id=?", (claim_id,)).fetchone() + if not r: + return None + return Claim(r["id"], r["habitat_id"], r["job_id"], r["claim"], r["action"], r["expected_status"], _dt(r["created_at"]), _dt(r["verified_at"]), r["status"], _json_object(r["evidence"], "claim.evidence"), r["run_id"]) + + def save_claim(self, c): + self.conn.execute("INSERT OR REPLACE INTO claim VALUES (?,?,?,?,?,?,?,?,?,?,?)", (c.id, c.habitat_id, c.job_id, c.claim, c.action, c.expected_status, c.created_at.isoformat(), c.verified_at.isoformat() if c.verified_at else None, c.status, json.dumps(c.evidence), c.run_id)) + if self._transaction_depth == 0: + self.conn.commit() + + def claims(self, limit=100): + return [Claim(r["id"], r["habitat_id"], r["job_id"], r["claim"], r["action"], r["expected_status"], _dt(r["created_at"]), _dt(r["verified_at"]), r["status"], _json_object(r["evidence"], "claim.evidence"), r["run_id"]) for r in self.conn.execute("SELECT * FROM claim ORDER BY created_at DESC LIMIT ?", (limit,))] + + def save_event(self, event_id, habitat_id, event_type, received_at, signature_valid, payload, agent_id=None): + outer = self._transaction_depth == 0 + if outer: + self.conn.execute("BEGIN IMMEDIATE") + try: + existing = self.conn.execute("SELECT habitat_id,type,payload,agent_id FROM event WHERE id=?", (event_id,)).fetchone() + if existing: + same = existing["habitat_id"] == habitat_id and existing["type"] == event_type and json.loads(existing["payload"]) == payload and existing["agent_id"] == agent_id + if outer: + self.conn.commit() + if not same: + raise ValueError("event_id_conflict") + return False + cur = self.conn.execute("INSERT INTO event VALUES (?,?,?,?,?,?,?,?)", (event_id, habitat_id, event_type, received_at, int(signature_valid), json.dumps(payload, sort_keys=True, allow_nan=False), payload.get("correlation_id") or payload.get("run_id"), agent_id)) + if outer: + self.conn.commit() + return cur.rowcount == 1 + except Exception: + if outer: + self.conn.rollback() + raise + + def events(self, limit=100): + return [{"id": r["id"], "habitat_id": r["habitat_id"], "type": r["type"], "received_at": r["received_at"], "signature_valid": bool(r["signature_valid"]), "payload": _json_object(r["payload"], "event.payload"), "correlation_id": r["correlation_id"], "agent_id": r["agent_id"]} for r in self.conn.execute("SELECT * FROM event ORDER BY received_at DESC LIMIT ?", (limit,))] + + def active_signals(self, limit=100): + return [x for x in self.signals() if x.resolved_at is None][:limit] + + @staticmethod + def hash_secret(secret): + """Return a versioned, salted password-style hash for agent secrets.""" + if not isinstance(secret, str) or not secret: + raise ValueError("agent secret must be non-empty") + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", secret.encode("utf-8"), salt, PBKDF2_ITERATIONS) + return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}" + + @staticmethod + def _verify_secret(secret, stored): + if not isinstance(secret, str) or not stored: + return False, False + if stored.startswith("pbkdf2_sha256$"): + try: + algorithm, iterations_raw, salt_hex, digest_hex = stored.split("$", 3) + iterations = int(iterations_raw) + if algorithm != "pbkdf2_sha256" or iterations < 100_000 or iterations > 2_000_000: + return False, False + salt = bytes.fromhex(salt_hex) + expected = bytes.fromhex(digest_hex) + candidate = hashlib.pbkdf2_hmac("sha256", secret.encode("utf-8"), salt, iterations) + return hmac.compare_digest(candidate, expected), False + except (TypeError, ValueError): + return False, False + # Backward-compatible verification for databases created before the + # hardened format. Successful legacy authentication is upgraded in place. + if len(stored) == 64: + candidate = hashlib.sha256(secret.encode("utf-8")).hexdigest() + return hmac.compare_digest(candidate, stored), True + return False, False + + def save_agent(self, agent_id, habitat_id, name, enabled=True, permissions=None, secret=None): + self.conn.execute("INSERT OR REPLACE INTO agent VALUES (?,?,?,?,?,?,?,?)", (agent_id, habitat_id, name, int(enabled), json.dumps(sorted(permissions or [])), self.hash_secret(secret) if secret else None, datetime.now().astimezone().isoformat(), None)) + if self._transaction_depth == 0: + self.conn.commit() + + def agents(self): + return [{"id": r["id"], "habitat_id": r["habitat_id"], "name": r["name"], "enabled": bool(r["enabled"]), "permissions": json.loads(r["permissions"]), "created_at": r["created_at"], "last_seen_at": r["last_seen_at"]} for r in self.conn.execute("SELECT * FROM agent ORDER BY name")] + + def authenticate_agent(self, agent_id, secret, permission): + r = self.conn.execute("SELECT * FROM agent WHERE id=?", (agent_id,)).fetchone() + if not r or not r["enabled"]: + return False + perms = json.loads(r["permissions"]) + if permission not in perms and "*" not in perms: + return False + if not r["secret_hash"]: + return False + valid, legacy = self._verify_secret(secret, r["secret_hash"]) + if not valid: + return False + if legacy: + self.conn.execute("UPDATE agent SET secret_hash=? WHERE id=?", (self.hash_secret(secret), agent_id)) + self.conn.execute("UPDATE agent SET last_seen_at=? WHERE id=?", (datetime.now().astimezone().isoformat(), agent_id)) + if self._transaction_depth == 0: + self.conn.commit() + return True + + def verify_action_integrity(self): + marker = self.conn.execute("SELECT value FROM schema_meta WHERE key='action_integrity_initialized'").fetchone() + if not marker or marker[0] != "1": + return False + rows = self.conn.execute("SELECT ai.seq,ai.action_id,ai.prev_hash,ai.hash,a.* FROM action_integrity ai JOIN action a ON a.id=ai.action_id ORDER BY ai.seq").fetchall() + prev = None + if not rows and self.conn.execute("SELECT 1 FROM action LIMIT 1").fetchone(): + return False + if rows and len(rows) != self.conn.execute("SELECT COUNT(*) FROM action").fetchone()[0]: + return False + for r in rows: + a = Action(r["id"], r["habitat_id"], _dt(r["timestamp"]), r["actor"], r["action"], r["status"], r["job_id"], _json_object(r["details"], "action.details"), r["run_id"]) + if r["prev_hash"] != prev or r["hash"] != self._action_hash(a, prev): + return False + prev = r["hash"] + return True From a0045124f0d37a8ea77fc06448649be6adde0208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:48:59 +0200 Subject: [PATCH 25/99] Integrate Habitat runtime: add habitat/trust.py --- habitat/trust.py | 77 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 habitat/trust.py diff --git a/habitat/trust.py b/habitat/trust.py new file mode 100644 index 0000000..5d63c6a --- /dev/null +++ b/habitat/trust.py @@ -0,0 +1,77 @@ +"""Small trust registry for signed proof verification. + +A valid Ed25519 signature is not treated as trusted until its key ID and public +key are present in the caller's registry. An embedded agent ID is only trusted +when the registry explicitly binds that key to the same agent. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Iterable + +from .proof_sign import verify_signed_proof + + +@dataclass(frozen=True) +class TrustedKey: + key_id: str + public_key: str + agent_id: str | None = None + expires_at: datetime | None = None + enabled: bool = True + + +class TrustRegistry: + def __init__(self, keys: Iterable[TrustedKey] = ()): + self._keys = {key.key_id: key for key in keys} + + def add(self, key: TrustedKey) -> None: + self._keys[key.key_id] = key + + def revoke(self, key_id: str) -> None: + key = self._keys.get(key_id) + if key: + self._keys[key_id] = TrustedKey(key.key_id, key.public_key, key.agent_id, key.expires_at, False) + + def get(self, key_id: str) -> TrustedKey | None: + return self._keys.get(key_id) + + def is_trusted(self, key_id: str, public_key: str, now: datetime | None = None, agent_id: str | None = None) -> bool: + key = self._keys.get(key_id) + if not key or not key.enabled or key.public_key != public_key: + return False + # If a caller wants attribution to an agent, the trust anchor must bind + # that key to the same agent. An unbound key authenticates only the key, + # not an arbitrary identity string carried by the proof. + if key.agent_id is None: + if agent_id is not None: + return False + elif key.agent_id != agent_id: + return False + current = now or datetime.now(timezone.utc) + if current.tzinfo is None or current.utcoffset() is None: + current = current.replace(tzinfo=timezone.utc) + expiry = key.expires_at + if expiry is None: + return True + if expiry.tzinfo is None or expiry.utcoffset() is None: + expiry = expiry.replace(tzinfo=timezone.utc) + return current < expiry + + def verify(self, bundle: dict, now: datetime | None = None, agent_id: str | None = None) -> bool: + signature = bundle.get("signature") + if not isinstance(signature, dict): + return False + key_id = signature.get("key_id") + public_key = signature.get("public_key") + signed_agent = signature.get("agent_id") + if agent_id is not None and signed_agent != agent_id: + return False + # A proof that carries an agent identity must have that identity bound by + # the trust anchor; otherwise the identity would be unauthenticated + # metadata even though the signature itself is valid. + expected_agent = agent_id if agent_id is not None else signed_agent + if signed_agent is not None and expected_agent is None: + return False + return bool(key_id and public_key and self.is_trusted(key_id, public_key, now, expected_agent) and verify_signed_proof(bundle)) From 209b03475e7ecbb1e1f32979084c657ea74cfa04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:49:04 +0200 Subject: [PATCH 26/99] Integrate Habitat runtime: add habitat/verdicts.py --- habitat/verdicts.py | 73 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 habitat/verdicts.py diff --git a/habitat/verdicts.py b/habitat/verdicts.py new file mode 100644 index 0000000..20d9adb --- /dev/null +++ b/habitat/verdicts.py @@ -0,0 +1,73 @@ +"""Reason-code -> human explanation map (DESIGN_SYSTEM §2.3). + +Single source of truth for verdict explanations, consumed by the CLI +(`verify`, `prove`) so a negative verdict is never a dead end. Each entry +names what the verdict means and what would change it. +""" +from __future__ import annotations + +REASON_EXPLANATIONS: dict[str, dict[str, str]] = { + "no_matching_trusted_action": { + "meaning": "No recorded action matches this claim (wrong or missing run_id).", + "next_action": "Record the action first (habitat run, habitat demo, or POST /v1/events), or pass the correct --run-id.", + "what_would_change": "A trusted action with this run_id, action and status recorded in the local ledger.", + }, + "run_id_required_for_ambiguous_trusted_verification": { + "meaning": "Multiple recorded actions match this claim; the run is ambiguous.", + "next_action": "Pass --run-id to pin the claim to one run.", + "what_would_change": "An explicit --run-id selecting exactly one of the matching actions.", + }, + "action_ledger_integrity_check_failed": { + "meaning": "The local hash chain over recorded actions does not verify.", + "next_action": "Run habitat doctor. Do not export proofs from this ledger.", + "what_would_change": "A ledger whose action hash chain verifies again (restore a clean backup if needed).", + }, + "matching_action_has_different_status": { + "meaning": "A matching action exists, but its status differs from the claim's expected status.", + "next_action": "See observed vs expected in the evidence; adjust the claim or the expected status.", + "what_would_change": "A matching action recorded with the expected status.", + }, + "claim_requires_correlation_or_external_evidence": { + "meaning": "The claim has no run, job or action correlation and no external evidence was supplied.", + "next_action": "Pass --run-id (or --job/--action), or supply external evidence.", + "what_would_change": "Correlation to a recorded action, or explicit external evidence that matches.", + }, + "habitat_mismatch": { + "meaning": "The claim belongs to a different Habitat than this database.", + "next_action": "Use the database that the claim was created in, or create the claim in this Habitat.", + "what_would_change": "A claim whose habitat_id matches this database.", + }, + "external_evidence_did_not_match": { + "meaning": "External evidence was observed, but it does not match the claim's expected status or data.", + "next_action": "See observed vs expected above; adjust the claim or supply different evidence.", + "what_would_change": "External evidence matching the expected status and data.", + }, + "not_initialized": { + "meaning": "No Habitat database has been initialized.", + "next_action": "Run: habitat init --config examples/simple.yaml", + "what_would_change": "An initialized Habitat database (habitat init).", + }, +} + +BOUNDARY_SENTENCES: dict[str, str] = { + "verified": "This shows the claim matches evidence in the local ledger. It does not prove the claim is true in the world.", + "inconclusive": "This shows the evidence is insufficient — here is what would change the verdict: see next_action.", + "rejected": "This shows the evidence does not support the claim. The claim and the evidence are both shown above.", + "demo": "This is sample demo data, not a live verification. Run tools/verify_proof.py for the real verdict.", +} + + +def explain_reason(reason: str | None) -> dict[str, str] | None: + """Return {reason, meaning, next_action, what_would_change} or None.""" + if not reason: + return None + entry = REASON_EXPLANATIONS.get(reason) + if not entry: + return None + return {"reason": reason, **entry} + + +def boundary_sentence(verdict: str | None) -> str | None: + if not verdict: + return None + return BOUNDARY_SENTENCES.get(verdict) From 8e35d3058280e5305292d29ad255defa3c79ade9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:49:07 +0200 Subject: [PATCH 27/99] Integrate Habitat runtime: add habitat/verify.py --- habitat/verify.py | 113 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 habitat/verify.py diff --git a/habitat/verify.py b/habitat/verify.py new file mode 100644 index 0000000..b2b812c --- /dev/null +++ b/habitat/verify.py @@ -0,0 +1,113 @@ +"""Deterministic claim verification with exact run correlation and policy gates.""" +from __future__ import annotations + +from typing import Any + +from .adapters import EvidenceAdapter +from .claims import Claim +from .policy import EvidencePolicy, apply_policy +from .schema import utcnow +from .store import Store + + +def _matches_expected(data: Any, expected: dict[str, Any] | None) -> bool: + if not expected: + return True + return isinstance(data, dict) and all(data.get(k) == v for k, v in expected.items()) + + +def _finish(claim: Claim, store: Store, policy: EvidencePolicy | None = None) -> Claim: + if policy and claim.status == "verified": + decision = apply_policy(policy, claim.evidence) + claim.evidence = {**claim.evidence, "policy": decision} + if decision["status"] != "allowed": + claim.status = "rejected" + claim.verified_at = utcnow() + store.save_claim(claim) + return claim + + +def verify_claim( + store: Store, + claim: Claim, + evidence_adapter: EvidenceAdapter | None = None, + evidence_query: str | None = None, + evidence_expected: dict[str, Any] | None = None, + policy: EvidencePolicy | None = None, +) -> Claim: + """Verify a claim, then apply an optional policy as a final non-positive gate. + + Policies can only downgrade a verified result; they can never turn missing or + rejected evidence into a positive verification. + """ + if claim.habitat_id != store.habitat().id: + claim.status = "rejected" + claim.evidence = {"source": "habitat", "reason": "habitat_mismatch"} + return _finish(claim, store, policy) + if not store.verify_action_integrity(): + claim.status = "inconclusive" + claim.evidence = {"source": "habitat_trusted_ledger", "reason": "action_ledger_integrity_check_failed"} + return _finish(claim, store, policy) + + has_correlation = bool(claim.run_id or claim.job_id or claim.action) + matches = store.matching_actions(claim.habitat_id, claim.job_id, claim.action, claim.run_id) if has_correlation else [] + + # A claim without a run/job/action correlation has no trustworthy basis in + # Habitat's action ledger. It must never be verified from an unrelated action. + if not has_correlation and not evidence_adapter: + claim.status = "rejected" + claim.evidence = {"source": "habitat_trusted_ledger", "reason": "claim_requires_correlation_or_external_evidence"} + return _finish(claim, store, policy) + + # A claim without an explicit run_id may only be positively verified from the + # trusted ledger when exactly one correlated action exists. The presence of an + # external evidence adapter must never make an ambiguous trusted action look + # attributable to this claim. + if claim.run_id is None and (claim.job_id or claim.action): + if len(matches) == 1: + trusted = matches[0] if matches[0].status == claim.expected_status else None + if trusted: + claim.status = "verified" + claim.evidence = {"source": "habitat_trusted_ledger", "action_id": trusted.id, "action": trusted.action, "status": trusted.status, "timestamp": trusted.timestamp.isoformat(), "run_id": trusted.run_id, "legacy_match": True, "details": trusted.details} + else: + claim.status = "rejected" + claim.evidence = {"source": "habitat_trusted_ledger", "reason": "matching_action_has_different_status", "legacy_match": True} + return _finish(claim, store, policy) + if len(matches) > 1: + if not evidence_adapter or not evidence_query: + claim.status = "inconclusive" + claim.evidence = {"source": "habitat_trusted_ledger", "reason": "run_id_required_for_ambiguous_trusted_verification"} + return _finish(claim, store, policy) + # Continue to external evidence only; do not fall through to an + # arbitrary trusted action below. + elif not evidence_adapter or not evidence_query: + claim.status = "rejected" + claim.evidence = {"source": "habitat_trusted_ledger", "reason": "no_matching_trusted_action"} + return _finish(claim, store, policy) + + if claim.run_id is not None or (has_correlation and len(matches) <= 1): + trusted = next((a for a in matches if a.status == claim.expected_status), None) + if trusted: + claim.status = "verified" + claim.evidence = {"source": "habitat_trusted_ledger", "action_id": trusted.id, "action": trusted.action, "status": trusted.status, "timestamp": trusted.timestamp.isoformat(), "run_id": trusted.run_id, "details": trusted.details} + return _finish(claim, store, policy) + if matches: + claim.status = "rejected" + claim.evidence = {"source": "habitat_trusted_ledger", "reason": "matching_action_has_different_status", "observed": [{"action": a.action, "status": a.status, "run_id": a.run_id} for a in matches[:5]]} + return _finish(claim, store, policy) + + if evidence_adapter and evidence_query: + evidence = evidence_adapter.observe(evidence_query) + if evidence.observed and evidence.status == claim.expected_status and _matches_expected(evidence.data, evidence_expected): + claim.status = "verified" + claim.evidence = {"source": evidence.source, "status": evidence.status, "data": evidence.data, "query": evidence_query, "expected": evidence_expected or {}} + elif evidence.error: + claim.status = "inconclusive" + claim.evidence = {"source": evidence.source, "reason": evidence.error, "query": evidence_query} + else: + claim.status = "rejected" + claim.evidence = {"source": evidence.source, "reason": "external_evidence_did_not_match", "status": evidence.status, "data": evidence.data, "query": evidence_query, "expected": evidence_expected or {}} + else: + claim.status = "rejected" + claim.evidence = {"source": "habitat_trusted_ledger", "reason": "no_matching_trusted_action"} + return _finish(claim, store, policy) From 5f95a10ba60d3f4badf0408e51dca0b68527a343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:49:10 +0200 Subject: [PATCH 28/99] Integrate Habitat runtime: add habitat/verify_api.py --- habitat/verify_api.py | 61 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 habitat/verify_api.py diff --git a/habitat/verify_api.py b/habitat/verify_api.py new file mode 100644 index 0000000..551378f --- /dev/null +++ b/habitat/verify_api.py @@ -0,0 +1,61 @@ +"""Machine-consumable claim verification and portable proof responses.""" +from __future__ import annotations + +from typing import Any + +from .proof import build_proof +from .verify import verify_claim + + +def verify_and_prove( + store: Any, + claim_id: str, + evidence_adapter: Any | None = None, + evidence_query: str | None = None, + evidence_expected: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Re-verify a claim, then return its current verdict and proof bundle. + + External evidence cannot be freshly re-verified without the adapter that + originally supplied it. In that case this function returns the existing + decision without mutating it, rather than silently downgrading the claim. + """ + claim = store.get_claim(claim_id) + if claim is None: + raise KeyError(f"Unknown claim: {claim_id}") + + evidence = claim.evidence if isinstance(claim.evidence, dict) else {} + source = evidence.get("source") + if source and source != "habitat_trusted_ledger" and evidence_adapter is None: + proof = build_proof(store, claim.id) + return { + "claim_id": claim.id, + "verdict": "inconclusive", + "stored_verdict": claim.status, + "verified_at": claim.verified_at.isoformat() if claim.verified_at else None, + "reverified": False, + "reverify_reason": "external_evidence_requires_adapter", + "proof": proof, + } + + claim = verify_claim(store, claim, evidence_adapter, evidence_query, evidence_expected) + proof = build_proof(store, claim.id) + return { + "claim_id": claim.id, + "verdict": claim.status, + "verified_at": claim.verified_at.isoformat() if claim.verified_at else None, + "reverified": True, + "proof": proof, + } + + +def proof_status(store: Any, claim_id: str) -> dict[str, Any]: + """Return proof without changing the stored claim decision.""" + proof = build_proof(store, claim_id) + return { + "claim_id": claim_id, + "verdict": proof["claim"]["status"], + "ledger_integrity": proof["ledger"]["integrity"], + "content_sha256": proof["content_sha256"], + "proof": proof, + } From 29a09d93400d79a8b17210a6e8ee20e178a8dfc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:50:02 +0200 Subject: [PATCH 29/99] Integrate Habitat read-only evidence bridge --- agentscope_habitat.py | 136 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 agentscope_habitat.py diff --git a/agentscope_habitat.py b/agentscope_habitat.py new file mode 100644 index 0000000..13d98c3 --- /dev/null +++ b/agentscope_habitat.py @@ -0,0 +1,136 @@ +"""Read-only bridge from AgentScope to Habitat's local evidence database.""" +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime +from pathlib import Path +from urllib.parse import quote + +from habitat.schema import Action +from habitat.store import Store + + +def _read_json_object(value): + decoded = json.loads(value or "{}") + return decoded if isinstance(decoded, dict) else {} + + +def _verify_ledger(conn): + marker = conn.execute( + "SELECT value FROM schema_meta WHERE key='action_integrity_initialized'" + ).fetchone() + if not marker or marker[0] != "1": + return "unknown" + rows = conn.execute( + "SELECT ai.seq, ai.action_id, ai.prev_hash, ai.hash, a.* " + "FROM action_integrity ai JOIN action a ON a.id=ai.action_id ORDER BY ai.seq" + ).fetchall() + action_count = conn.execute("SELECT COUNT(*) FROM action").fetchone()[0] + if len(rows) != action_count: + return "failed" + previous = None + for row in rows: + try: + action = Action( + row["id"], row["habitat_id"], datetime.fromisoformat(row["timestamp"]), + row["actor"], row["action"], row["status"], row["job_id"], + _read_json_object(row["details"]), row["run_id"], + ) + if row["prev_hash"] != previous or row["hash"] != Store._action_hash(action, previous): + return "failed" + previous = row["hash"] + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + return "failed" + return "intact" + + +def load_habitat_snapshot(database_path): + """Return a bounded, read-only operational summary; never mutate Habitat state.""" + if not database_path: + return None + path = Path(database_path).expanduser() + if not path.is_file(): + return {"available": False, "reason": "database_missing", "path": str(path)} + uri = "file:" + quote(str(path.resolve()), safe="/") + "?mode=ro" + try: + conn = sqlite3.connect(uri, uri=True, timeout=2) + conn.row_factory = sqlite3.Row + try: + tables = {row[0] for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + )} + required = {"habitat", "job", "signal", "claim", "action", "action_integrity", "schema_meta"} + if not required.issubset(tables): + return {"available": False, "reason": "incomplete_schema", "path": str(path)} + habitat = conn.execute( + "SELECT id, name, status, model, updated_at FROM habitat LIMIT 1" + ).fetchone() + if habitat is None: + return {"available": False, "reason": "not_initialized", "path": str(path)} + jobs = conn.execute( + "SELECT COUNT(*) total, SUM(CASE WHEN enabled=1 THEN 1 ELSE 0 END) enabled " + "FROM job WHERE habitat_id=?", (habitat["id"],) + ).fetchone() + signals = conn.execute( + "SELECT severity, COUNT(*) total FROM signal WHERE habitat_id=? " + "AND resolved_at IS NULL GROUP BY severity", (habitat["id"],) + ).fetchall() + claims = conn.execute( + "SELECT status, COUNT(*) total FROM claim WHERE habitat_id=? GROUP BY status", + (habitat["id"],) + ).fetchall() + return { + "available": True, + "name": habitat["name"], + "status": habitat["status"], + "model": habitat["model"], + "updated_at": habitat["updated_at"], + "jobs_total": jobs["total"] or 0, + "jobs_enabled": jobs["enabled"] or 0, + "active_signals": {row["severity"]: row["total"] for row in signals}, + "claims": {row["status"]: row["total"] for row in claims}, + "ledger_integrity": _verify_ledger(conn), + } + finally: + conn.close() + except (OSError, sqlite3.Error, ValueError) as exc: + return {"available": False, "reason": "database_unreadable", "detail": str(exc)[:160], "path": str(path)} + + +def render_habitat_panel(snapshot): + """Render an escaped, read-only Habitat summary for the generated dashboard.""" + if snapshot is None: + return "" + import html + + esc = html.escape + if not snapshot.get("available"): + reason = snapshot.get("reason", "unknown") + return ( + '

Habitat evidence runtime

' + '
UNKNOWN ' + 'Habitat state unavailable (' + esc(reason) + '). No assurance status is inferred.
' + ) + claims = snapshot.get("claims", {}) + signals = snapshot.get("active_signals", {}) + signal_count = sum(signals.values()) + ledger = snapshot.get("ledger_integrity", "unknown") + ledger_label = {"intact": "INTACT", "failed": "FAILED", "unknown": "UNKNOWN"}.get(ledger, "UNKNOWN") + ledger_color = "#15803d" if ledger == "intact" else "#b91c1c" + return ( + '

Habitat evidence runtime

' + '
' + esc(snapshot.get("name", "Habitat")) + ' · runtime ' + + esc(snapshot.get("status", "unknown")) + ' · ' + + str(snapshot.get("jobs_enabled", 0)) + '/' + str(snapshot.get("jobs_total", 0)) + ' jobs enabled
' + '
Active signals: ' + str(signal_count) + + ' · Claims recorded: ' + str(sum(claims.values())) + + ' (verified ' + str(claims.get("verified", 0)) + + ', rejected ' + str(claims.get("rejected", 0)) + + ', inconclusive ' + str(claims.get("inconclusive", 0)) + ')
' + '
Action ledger: ' + ledger_label + '' + + (' · Active critical signals: ' + str(signals.get("critical", 0)) if signals.get("critical") else '') + + (' · Active warnings: ' + str(signals.get("warning", 0)) if signals.get("warning") else '') + + '
Read-only snapshot. Recorded claim verdicts are not independently re-verified by this dashboard.
' + ) From a4cb7d552e9c099a692c8369bd1c8a7afaf5bd9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:50:16 +0200 Subject: [PATCH 30/99] Connect AgentScope dashboard to Habitat evidence runtime --- generate.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/generate.py b/generate.py index a5fb156..66fe646 100644 --- a/generate.py +++ b/generate.py @@ -16,6 +16,8 @@ import argparse, json, sqlite3, os, glob, html, re from datetime import datetime, timedelta +from agentscope_habitat import load_habitat_snapshot, render_habitat_panel + # ---------------------------------------------------------------- helpers --- def parse_ts(value): @@ -131,6 +133,7 @@ def success_rates(execs, min_runs=3): "leads_query": None, "leads_label": "Active items", "mcp_jsonl": None, # optional JSONL of MCP/tool calls + "habitat_db": None, # optional read-only Habitat SQLite database "ack_db": "~/.hermes/cron/executions.db", # sqlite table for acknowledged operational alerts }, "session_days": 7, @@ -428,7 +431,12 @@ def build(cfg, out_path): execs = recent_executions(s["executions_db"]) if s.get("executions_db") else [] leads = counter_summary(s.get("leads_db"), *(s.get("leads_query") or (None, None))) or {"total": 0, "active": 0} sess = session_activity(s.get("sessions_dir"), cfg["session_days"]) if s.get("sessions_dir") else {} + habitat_snapshot = load_habitat_snapshot(s.get("habitat_db")) source_anom = source_health(s) + if habitat_snapshot and not habitat_snapshot.get("available"): + source_anom.append(("warn", "Habitat evidence runtime unavailable — assurance state is UNKNOWN.", ack_key("warn", "Habitat evidence runtime unavailable"))) + elif habitat_snapshot and habitat_snapshot.get("ledger_integrity") == "failed": + source_anom.append(("fail", "Habitat action ledger integrity check FAILED.", ack_key("fail", "Habitat action ledger integrity check FAILED"))) operational_anom = anomalies(jobs, execs, cfg.get("never_run_grace_hours", 48)) if source_anom: operational_anom = [item for item in operational_anom if not (item[0] == "ok" and item[1].startswith("All clear"))] @@ -448,6 +456,7 @@ def within_24h(e): names = job_names(s["cron_jobs_glob"], s["executions_db"]) if s.get("executions_db") else {} rates = success_rates(execs) mcp_rows = mcp_tool_stats(s["mcp_jsonl"]) if s.get("mcp_jsonl") else [] + habitat_panel = render_habitat_panel(habitat_snapshot) job_rows = "".join( f'{esc(j["name"])}{esc(j["schedule"])}' @@ -596,7 +605,7 @@ def _job_row(j): }}catch(e){{}} }})(); -{mcp_panel}

Agent sessions ({cfg['session_days']}d)

{esc(sess_spark)}
+{mcp_panel}{habitat_panel}

Agent sessions ({cfg['session_days']}d)

{esc(sess_spark)}
From 493ef8e1abb56ee62380858e0e48655a53a7cb51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:50:27 +0200 Subject: [PATCH 31/99] Package Habitat runtime within AgentScope distribution --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2df069c..6c5041a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,4 +10,7 @@ requires-python = ">=3.11" dependencies = [] [tool.setuptools] -py-modules = ["generate"] +py-modules = ["generate", "agentscope_habitat"] + +[tool.setuptools.packages.find] +include = ["habitat*"] From 5a0a0640ecd3e213db29d94bfafdabc7aec6c729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:50:30 +0200 Subject: [PATCH 32/99] Expand configured Habitat database path --- generate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generate.py b/generate.py index 66fe646..784da3c 100644 --- a/generate.py +++ b/generate.py @@ -157,7 +157,7 @@ def load_config(path): else: cfg[k] = v s = cfg["sources"] - for k in ("cron_jobs_glob", "executions_db", "sessions_dir", "leads_db", "ack_db", "mcp_jsonl"): + for k in ("cron_jobs_glob", "executions_db", "sessions_dir", "leads_db", "ack_db", "mcp_jsonl", "habitat_db"): if s.get(k): s[k] = os.path.expanduser(s[k]) return cfg From 9b2bb4599606212f7c1ff14cec0e50b33f2ab2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:50:59 +0200 Subject: [PATCH 33/99] Document AgentScope and Habitat integration boundaries --- docs/INTEGRATION-HABITAT.md | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/INTEGRATION-HABITAT.md diff --git a/docs/INTEGRATION-HABITAT.md b/docs/INTEGRATION-HABITAT.md new file mode 100644 index 0000000..c1ca695 --- /dev/null +++ b/docs/INTEGRATION-HABITAT.md @@ -0,0 +1,56 @@ +# AgentScope + Habitat integration + +## Decision + +AgentScope remains the user-facing product and generated supervision dashboard. Habitat is integrated as the local evidence, claim-verification, policy, action-ledger, and proof runtime. + +This is a single product experience with a separately bounded evidence subsystem. It is not an orchestration/control plane: AgentScope remains read-only and the dashboard bridge never executes jobs, changes claims, acknowledges signals, or mutates Habitat state. + +## What is integrated on this branch + +- The Habitat Python runtime is vendored under `habitat/` from upstream Habitat commit `b823836d826a73f1edcea3ff0c5b7ba7399a6219`. +- AgentScope packaging now includes the Habitat package. +- `agentscope_habitat.py` reads an explicitly configured Habitat SQLite database using SQLite read-only mode. +- The generated AgentScope dashboard can display Habitat runtime health, job counts, active signals, stored claim verdict counts, and action-ledger integrity. +- Missing/unreadable Habitat state is shown as UNKNOWN, never as healthy. +- A failed action-ledger integrity check is surfaced as a failure. +- The bridge labels claim verdicts as recorded state; it does not pretend to freshly re-verify them. + +## Configuration + +Set `sources.habitat_db` to the path of an existing initialized Habitat SQLite database. Leave it null to preserve AgentScope's current behaviour. + +Example: + +```json +{ + "sources": { + "habitat_db": "~/.habitat/habitat.db" + } +} +``` + +Use the actual database path configured for the local Habitat installation; the example path is illustrative. + +## Trust boundaries + +1. AgentScope opens the database with SQLite `mode=ro`; it does not run Habitat migrations or write to the database. +2. A dashboard snapshot is observational, not an external attestation. +3. A stored `verified` claim is displayed as a stored verdict. Re-verification requires the Habitat verification flow and its original evidence adapters. +4. A proof bundle's content integrity, signature validity, publisher trust, and truth in the outside world remain distinct questions. +5. The original Habitat repository remains intact as the upstream source until this integration passes CI, regression, security, and live-deployment acceptance. + +## Validation still required before release + +- Run the full AgentScope unittest suite and Habitat test suite in the unified repository. +- Add fixture coverage for malformed, migrated, and tampered Habitat databases. +- Verify no read-only bridge path writes, migrates, or locks the source database. +- Exercise the dashboard with no Habitat DB, a healthy DB, active signals, failed ledger integrity, and recorded claims. +- Inspect the live Vercel deployment and verify the intended runtime mode. The public AgentScope website is currently a static product/demo surface; it must not be mistaken for a hosted Habitat database. +- Decide whether to keep Habitat as an independently installable CLI distribution or expose the runtime through an optional extra after packaging and release tests. + +## Upstream provenance + +- AgentScope source: `buttercode101/agentscope`, main baseline at integration branch creation. +- Habitat source: `buttercode101/Habitat`, `b823836d826a73f1edcea3ff0c5b7ba7399a6219`. +- Both upstream repositories are MIT licensed. Keep their notices and attribution when redistributing integrated code. From ae80020c68ba317879e8ca6242ab7774ed32f546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rama=20=E2=80=94=20Buttercode=20Systems?= Date: Wed, 11 Nov 2026 11:51:01 +0200 Subject: [PATCH 34/99] Test read-only Habitat evidence bridge --- test_habitat_bridge.py | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 test_habitat_bridge.py diff --git a/test_habitat_bridge.py b/test_habitat_bridge.py new file mode 100644 index 0000000..486efcd --- /dev/null +++ b/test_habitat_bridge.py @@ -0,0 +1,48 @@ +import tempfile +import unittest +from pathlib import Path + +from agentscope_habitat import load_habitat_snapshot, render_habitat_panel +from habitat.schema import Habitat, Job +from habitat.store import Store + + +class HabitatBridgeTests(unittest.TestCase): + def test_missing_database_is_unknown_not_healthy(self): + with tempfile.TemporaryDirectory() as td: + result = load_habitat_snapshot(str(Path(td) / "missing.db")) + self.assertFalse(result["available"]) + self.assertEqual(result["reason"], "database_missing") + self.assertIn("UNKNOWN", render_habitat_panel(result)) + + def test_reads_habitat_state_and_ledger_without_mutating_source(self): + with tempfile.TemporaryDirectory() as td: + db = Path(td) / "habitat.db" + store = Store(db) + store.save_habitat(Habitat("h1", "Crew Alpha")) + store.save_job(Job("j1", "h1", "Nightly audit", enabled=True)) + store.close() + + before = db.read_bytes() + result = load_habitat_snapshot(str(db)) + after = db.read_bytes() + + self.assertTrue(result["available"]) + self.assertEqual(result["name"], "Crew Alpha") + self.assertEqual(result["jobs_total"], 1) + self.assertEqual(result["jobs_enabled"], 1) + self.assertEqual(result["ledger_integrity"], "intact") + self.assertEqual(before, after) + + def test_dashboard_escapes_habitat_name(self): + panel = render_habitat_panel({ + "available": True, "name": "", + "status": "healthy", "jobs_total": 0, "jobs_enabled": 0, + "active_signals": {}, "claims": {}, "ledger_integrity": "intact", + }) + self.assertNotIn("