Files
Elydora-Open-Source/sdks/python/elydora/plugins/hook_template.py
T

362 lines
12 KiB
Python

"""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)
'''