"""Generate self-contained audit hook scripts.""" from __future__ import annotations import sys from .guard_template import generate_guard_script from .runtime_reader_template import PROTECTED_RUNTIME_READER __all__ = ["generate_guard_script", "generate_hook_script"] def generate_hook_script( *, org_id: str, agent_id: str, kid: str, base_url: str, success_output: str = "", fail_closed: bool = False, native_payload: bool = False, agent_name: str = "", ) -> str: """Return a self-contained signed audit hook.""" return f'''#!{sys.executable} """Elydora audit hook. Generated by elydora-sdk.""" import base64 import hashlib import json import math import os import re import stat import struct import sys import time import urllib.error import urllib.parse import urllib.request ORG_ID = {org_id!r} AGENT_ID = {agent_id!r} AGENT_NAME = {agent_name!r} KID = {kid!r} BASE_URL = {base_url!r} SUCCESS_OUTPUT = {success_output!r} FAIL_CLOSED = {fail_closed!r} NATIVE_PAYLOAD = {native_payload!r} ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora") CONFIG_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "config.json") PRIVATE_KEY_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "private.key") CHAIN_STATE_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "chain-state.json") ERROR_LOG_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "error.log") ZERO_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" {PROTECTED_RUNTIME_READER} def base64url_encode(data): return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") def base64url_decode(value): encoded = value.replace("-", "+").replace("_", "/") encoded += "=" * ((4 - len(encoded) % 4) % 4) return base64.b64decode(encoded, validate=True) def sha256_base64url(data): if isinstance(data, str): data = data.encode("utf-8") return base64url_encode(hashlib.sha256(data).digest()) def jcs_number(value): if isinstance(value, bool): return "true" if value else "false" if isinstance(value, int): return str(value) if math.isnan(value) or math.isinf(value): return "null" if value == 0.0: return "0" return json.dumps(value) def jcs_canonicalize(value): if value is None: return "null" if isinstance(value, bool): return "true" if value else "false" if isinstance(value, (int, float)): return jcs_number(value) if isinstance(value, str): return json.dumps(value, ensure_ascii=False) if isinstance(value, list): return "[" + ",".join(jcs_canonicalize(item) for item in value) + "]" if isinstance(value, dict): pairs = [ json.dumps(key, ensure_ascii=False) + ":" + jcs_canonicalize(value[key]) for key in sorted(value) ] return "{{" + ",".join(pairs) + "}}" return json.dumps(value) def compute_payload_hash(payload): return sha256_base64url(jcs_canonicalize(payload)) def compute_chain_hash(previous, payload_hash, operation_id, issued_at): value = f"{{previous}}|{{payload_hash}}|{{operation_id}}|{{issued_at}}" return sha256_base64url(value) def read_private_key(): encoded = read_protected_file(PRIVATE_KEY_PATH, "Private key").decode("utf-8") if not encoded or "\\r" in encoded or "\\n" in encoded or "\\0" in encoded: raise ValueError("Private key file must contain exactly one line") seed = base64url_decode(encoded) if len(seed) != 32 or base64url_encode(seed) != encoded: raise ValueError("Private key must be a canonical 32-byte base64url value") return seed def sign_ed25519(seed, data): from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey return base64url_encode(Ed25519PrivateKey.from_private_bytes(seed).sign(data)) def generate_uuidv7(): timestamp = struct.pack(">Q", int(time.time() * 1000))[2:] random_a = (struct.unpack(">H", os.urandom(2))[0] & 0x0FFF) | 0x7000 random_b = bytearray(os.urandom(8)) random_b[0] = (random_b[0] & 0x3F) | 0x80 value = (timestamp + struct.pack(">H", random_a) + bytes(random_b)).hex() return f"{{value[0:8]}}-{{value[8:12]}}-{{value[12:16]}}-{{value[16:20]}}-{{value[20:32]}}" def log_error(error): descriptor = -1 try: message = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) message += " [elydora-hook] " + repr(error) + "\\n" flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT flags |= getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0) flags |= getattr(os, "O_NOFOLLOW", 0) descriptor = os.open(ERROR_LOG_PATH, flags, 0o600) metadata = os.fstat(descriptor) if not stat.S_ISREG(metadata.st_mode): raise ValueError("Error log path is not a physical file: " + ERROR_LOG_PATH) with os.fdopen(descriptor, "a", encoding="utf-8") as file: descriptor = -1 file.write(message) if os.name != "nt": os.chmod(ERROR_LOG_PATH, 0o600) except Exception as log_failure: sys.stderr.write("[Elydora audit] Failed to write error log: " + str(log_failure) + "\\n") finally: if descriptor >= 0: os.close(descriptor) def read_chain_state(): try: raw = read_protected_file( CHAIN_STATE_PATH, "Chain state", MAX_PROTECTED_CONFIG_BYTES, require_private_permissions=False, ).decode("utf-8") except FileNotFoundError: return ZERO_CHAIN_HASH except Exception as error: if FAIL_CLOSED: raise RuntimeError("Failed to read chain state: " + str(error)) from error log_error(error) return ZERO_CHAIN_HASH try: state = json.loads(raw) except (json.JSONDecodeError, ValueError) as error: if FAIL_CLOSED: raise RuntimeError("Chain state is invalid JSON: " + str(error)) from error log_error(error) return ZERO_CHAIN_HASH previous = state.get("prev_chain_hash") if isinstance(state, dict) else None if not isinstance(previous, str) or re.fullmatch(r"[A-Za-z0-9_-]{{43}}", previous) is None: error = ValueError("Chain state contains an invalid prev_chain_hash") if FAIL_CLOSED: raise error log_error(error) return ZERO_CHAIN_HASH return previous def write_chain_state(chain_hash): token = os.urandom(8).hex() temporary_path = CHAIN_STATE_PATH + "." + str(os.getpid()) + "." + token + ".tmp" descriptor = -1 try: flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) descriptor = os.open(temporary_path, flags, 0o600) with os.fdopen(descriptor, "w", encoding="utf-8") as file: descriptor = -1 json.dump({{"prev_chain_hash": chain_hash}}, file) file.flush() os.fsync(file.fileno()) os.replace(temporary_path, CHAIN_STATE_PATH) finally: if descriptor >= 0: os.close(descriptor) try: os.remove(temporary_path) except FileNotFoundError: pass def read_runtime_config(): if not FAIL_CLOSED: return {{ "org_id": ORG_ID, "agent_id": AGENT_ID, "agent_name": AGENT_NAME, "kid": KID, "base_url": BASE_URL, "token": "", }} try: raw = read_protected_file( CONFIG_PATH, "Agent config", MAX_PROTECTED_CONFIG_BYTES, ).decode("utf-8") config = json.loads(raw) except Exception as error: raise RuntimeError("Failed to read agent config: " + str(error)) from error if not isinstance(config, dict): raise ValueError("Agent config must contain a JSON object") if config.get("agent_id") != AGENT_ID or config.get("agent_name") != AGENT_NAME: raise ValueError("Agent config identity does not match the generated audit hook") for field in ("org_id", "agent_id", "kid"): if not isinstance(config.get(field), str) or not config[field]: raise ValueError("Agent config is missing " + field) base_url = config.get("base_url", "https://api.elydora.com") if not isinstance(base_url, str): raise ValueError("Agent config base_url must be a string") if urllib.parse.urlsplit(base_url).scheme not in ("http", "https"): raise ValueError("Agent config base_url must use HTTP or HTTPS") if not isinstance(config.get("token", ""), str): raise ValueError("Agent config token must be a string") return config def read_event(): raw = sys.stdin.read() if not raw.strip(): raise ValueError("Hook input is empty") try: event = json.loads(raw) except (json.JSONDecodeError, ValueError) as error: raise ValueError("Hook input is invalid JSON: " + str(error)) from error if not isinstance(event, dict): raise ValueError("Hook input must contain a JSON object") return event def submit_operation(event, runtime): nested = event.get("tool_result") if not isinstance(nested, dict): nested = event.get("tool_call") if not isinstance(nested, dict): nested = {{}} tool_name = event.get("tool_name", event.get("toolName", event.get("name", nested.get("name", "unknown")))) tool_input = event.get("tool_input", event.get("toolInput", event.get("input", nested.get("input", {{}})))) session_id = event.get( "conversation_id", event.get("session_id", event.get("sessionId", event.get("session", event.get("taskId", "unknown")))), ) previous = read_chain_state() operation_id = generate_uuidv7() issued_at = int(time.time() * 1000) payload = event if NATIVE_PAYLOAD else {{ "tool_name": tool_name, "tool_input": tool_input, "session_id": session_id, }} payload_hash = compute_payload_hash(payload) chain_hash = compute_chain_hash(previous, payload_hash, operation_id, issued_at) unsigned = {{ "op_version": "1.0", "operation_id": operation_id, "org_id": runtime["org_id"], "agent_id": runtime["agent_id"], "issued_at": issued_at, "ttl_ms": 30000, "nonce": base64url_encode(os.urandom(16)), "operation_type": "ai.tool_use", "subject": {{"session_id": session_id}}, "action": {{"tool": tool_name}}, "payload": payload, "payload_hash": payload_hash, "prev_chain_hash": previous, "agent_pubkey_kid": runtime["kid"], }} signature = sign_ed25519( read_private_key(), jcs_canonicalize(unsigned).encode("utf-8"), ) operation = dict(unsigned) operation.update({{"chain_hash": chain_hash, "signature": signature}}) url = runtime.get("base_url", "https://api.elydora.com").rstrip("/") + "/v1/operations" headers = {{"Content-Type": "application/json", "Accept": "application/json"}} if runtime.get("token"): headers["Authorization"] = "Bearer " + runtime["token"] request = urllib.request.Request( url, data=json.dumps(operation).encode("utf-8"), headers=headers, method="POST", ) try: with urllib.request.urlopen(request, timeout=5): write_chain_state(chain_hash) except urllib.error.HTTPError as error: body = error.read().decode("utf-8") if error.code == 400 and body: try: failure = json.loads(body) except (json.JSONDecodeError, ValueError) as parse_error: raise RuntimeError("Audit API returned invalid error JSON: " + str(parse_error)) from parse_error details = failure.get("error", {{}}) if isinstance(failure, dict) else {{}} if details.get("code") == "PREV_HASH_MISMATCH": match = re.search( r'Expected prev_chain_hash "([A-Za-z0-9_-]{{43}})"', str(details.get("message", "")), ) if match: write_chain_state(match.group(1)) log_error("Chain hash resynced to server: " + match.group(1)) raise RuntimeError("Audit API returned HTTP " + str(error.code)) from error def main(): try: submit_operation(read_event(), read_runtime_config()) except Exception as error: log_error(error) if FAIL_CLOSED: raise if __name__ == "__main__": try: main() except Exception as error: sys.stderr.write("[Elydora audit] " + repr(error) + "\\n") raise SystemExit(1) if SUCCESS_OUTPUT: sys.stdout.write(SUCCESS_OUTPUT) '''