Files
雷电芽衣 ed34831c20 chore(python): audit plugins shared modules
_managed_files owns the file snapshot reader and public metadata helpers used by pinned directories, durable copies, and opaque snapshots; transaction staging owns read_target and same_identity_and_contents; dead helpers removed; hook_template no longer re-exports the guard generator; mypy targets win32 so _windows_security type-checks.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-02 23:16:15 -04:00

432 lines
15 KiB
Python

"""Generate self-contained audit hook scripts."""
from __future__ import annotations
import sys
from .error_log_template import ERROR_LOG_RUNTIME
from .runtime_reader_template import PROTECTED_RUNTIME_READER
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
AGENT_ID = {agent_id!r}
AGENT_NAME = {agent_name!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"
MAX_CHAIN_ATTEMPTS = 5
SUBMIT_BUDGET_SECONDS = 4.0
{PROTECTED_RUNTIME_READER}
{ERROR_LOG_RUNTIME}
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):
try:
append_bounded_error_log(
ERROR_LOG_PATH,
"Error log",
bounded_error_message(error),
MAX_PROTECTED_LOG_BYTES,
)
except Exception as log_failure:
sys.stderr.write(
"[Elydora audit] Failed to write error log: "
+ str(log_failure)
+ "\\n"
)
def read_chain_state():
try:
raw = read_protected_file(
CHAIN_STATE_PATH,
"Chain state",
MAX_PROTECTED_CONFIG_BYTES,
).decode("utf-8")
except FileNotFoundError:
return ZERO_CHAIN_HASH
except Exception as error:
raise RuntimeError("Failed to read chain state: " + str(error)) from error
try:
state = json.loads(raw)
except (json.JSONDecodeError, ValueError) as error:
raise RuntimeError("Chain state is invalid JSON: " + str(error)) from error
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:
raise ValueError("Chain state contains an invalid prev_chain_hash")
return previous
def write_chain_state(chain_hash):
write_protected_json(
CHAIN_STATE_PATH,
"Chain state",
{{"prev_chain_hash": chain_hash}},
)
def lock_owner_alive(lock_path):
if os.name == "nt":
return False
try:
with open(lock_path, "r", encoding="ascii") as handle:
owner = int(handle.read().strip() or "0")
except (FileNotFoundError, ValueError):
return False
if owner <= 0:
return False
try:
os.kill(owner, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def with_chain_lock(update):
lock_path = CHAIN_STATE_PATH + ".lock"
deadline = time.monotonic() + 1.0
while True:
try:
descriptor = os.open(lock_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
os.write(descriptor, str(os.getpid()).encode("ascii"))
except FileExistsError:
if lock_owner_alive(lock_path):
if time.monotonic() > deadline:
raise RuntimeError("Chain state lock timed out: " + lock_path)
time.sleep(0.01)
continue
try:
age = time.time() - os.stat(lock_path).st_mtime
except FileNotFoundError:
age = 0.0
if age > 5.0:
reclaimed = lock_path + "." + str(os.getpid()) + ".stale"
try:
os.rename(lock_path, reclaimed)
os.unlink(reclaimed)
except FileNotFoundError:
pass
continue
if time.monotonic() > deadline:
raise RuntimeError("Chain state lock timed out: " + lock_path)
time.sleep(0.01)
continue
try:
return update()
finally:
try:
current = os.lstat(lock_path)
except FileNotFoundError:
current = None
own = os.fstat(descriptor)
os.close(descriptor)
if current is not None and current.st_ino == own.st_ino and current.st_dev == own.st_dev:
os.unlink(lock_path)
def advance_chain_state(from_hash, to_hash):
def update():
if read_chain_state() != from_hash:
return False
write_chain_state(to_hash)
return True
return with_chain_lock(update)
def read_runtime_config():
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:
raise ValueError("Agent config identity does not match the generated audit hook")
if AGENT_NAME and 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")
try:
parsed = urllib.parse.urlsplit(base_url)
hostname = parsed.hostname
parsed.port
except ValueError as error:
raise ValueError(
"Agent config base_url must be an absolute HTTP or HTTPS URL"
) from error
invalid_character = "\\\\" in base_url or any(
character.isspace() or ord(character) < 32 for character in base_url
)
if (
parsed.scheme not in ("http", "https")
or not parsed.netloc
or hostname is None
or invalid_character
):
raise ValueError("Agent config base_url must be an absolute HTTP or HTTPS URL")
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
raise ValueError(
"Agent config base_url must exclude credentials, query parameters, and fragments"
)
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 post_operation(operation, runtime, deadline):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise RuntimeError("Audit API retry budget exhausted")
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=min(5.0, remaining)):
return None
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:
return match.group(1)
raise RuntimeError("Audit API returned HTTP " + str(error.code)) from error
def build_operation(runtime, seed, payload, payload_hash, tool_name, session_id, previous):
operation_id = generate_uuidv7()
issued_at = int(time.time() * 1000)
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(seed, jcs_canonicalize(unsigned).encode("utf-8"))
operation = dict(unsigned)
operation.update({{"chain_hash": chain_hash, "signature": signature}})
return operation, chain_hash
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")))),
)
payload = event if NATIVE_PAYLOAD else {{
"tool_name": tool_name,
"tool_input": tool_input,
"session_id": session_id,
}}
payload_hash = compute_payload_hash(payload)
seed = read_private_key()
deadline = time.monotonic() + SUBMIT_BUDGET_SECONDS
previous = read_chain_state()
attempt = 0
while True:
attempt += 1
operation, chain_hash = build_operation(runtime, seed, payload, payload_hash, tool_name, session_id, previous)
expected = post_operation(operation, runtime, deadline)
if expected is None:
advance_chain_state(previous, chain_hash)
return
advance_chain_state(previous, expected)
log_error("Chain hash resynced to server: " + expected)
if attempt >= MAX_CHAIN_ATTEMPTS:
raise RuntimeError("Audit API rejected prev_chain_hash " + str(attempt) + " times")
previous = read_chain_state()
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)
'''