Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/local-audit-event-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@

## Storage and append-only rule

Store one event per UTF-8 newline-delimited JSON (JSONL) line, normally under the selected local AetherMesh home. `append_local_audit_event(path, event)` validates an event and opens the file only in append mode; it never reads, replaces, edits, or deletes prior records. Consumers must treat an existing line as immutable evidence. Local filesystem access can still alter files outside this API, so this phase does not claim tamper resistance.
Store one event per UTF-8 newline-delimited JSON (JSONL) line, normally under the selected local AetherMesh home. `append_local_audit_event(path, event)` sanitizes and validates an event before opening the file in append mode; it never reads, replaces, edits, or deletes prior records. Consumers must treat an existing line as immutable evidence. Local filesystem access can still alter files outside this API, so this phase does not claim tamper resistance.

## Log-safety policy

Audit events preserve intentionally safe accountability fields: node and work IDs, manifest hashes and references, validation receipt IDs, lineage, and contribution attribution. They should contain hashes, IDs, safe relative references, and short summaries instead of raw request or result content.

`sanitize_local_audit_event` recursively replaces values under secret-bearing keys (`token`, `secret`, `password`, `privateKey`, `apiKey`, `seed`, `credential`, and `authorization`, including common compound forms) with `[REDACTED]`. Environment maps and raw request payload fields are also redacted. Absolute Unix, home-relative, and Windows paths become `local-path/<name>` labels; callers should supply a project-relative artifact reference when that reference is needed for validation. Do not place environment-variable dumps, private keys, credentials, full prompts, request payloads, or node-local configuration bodies in audit fields.

Sanitization is a persistence boundary, not a general secret detector. Callers must still prefer the compact schema and must not hide arbitrary secret text inside an unrelated identifier or summary field.

## Required fields

Expand Down
80 changes: 79 additions & 1 deletion src/aethermesh_core/local_audit_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,45 @@

import json
import os
import re
from collections.abc import Mapping
from datetime import datetime
from pathlib import Path, PureWindowsPath
from typing import Any

AUDIT_EVENT_SCHEMA_VERSION = 2
AUDIT_REDACTED_VALUE = "[REDACTED]"
_SENSITIVE_KEY_PARTS = frozenset(
{
"token",
"secret",
"password",
"privatekey",
"apikey",
"seed",
"credential",
"authorization",
}
)
_PRIVATE_CONTENT_KEYS = frozenset(
{
"env",
"environment",
"environmentmap",
"environmentvariables",
"envmap",
"envvars",
"payload",
"rawpayload",
"rawrequest",
"rawrequestpayload",
"requestbody",
"requestpayload",
}
)
_LOCAL_PATH_IN_TEXT = re.compile(
r"(?<![A-Za-z0-9/])(?:file:///|~[/\\]|/(?!/)|[A-Za-z]:[/\\]|\\\\)[^\s\"']+"
)
AUDIT_EVENT_TYPES = frozenset(
{
"node_initialized",
Expand Down Expand Up @@ -98,6 +131,12 @@ class LocalAuditEventError(ValueError):
"""Raised when a local audit event does not match the canonical format."""


def sanitize_local_audit_event(event: Mapping[str, Any]) -> dict[str, Any]:
"""Return an audit-safe copy without secrets or host-specific absolute paths."""

return {key: _sanitize_audit_value(value, key=key) for key, value in event.items()}


def validate_local_audit_event(event: Mapping[str, Any]) -> dict[str, Any]:
"""Validate and return one canonical local audit event without writing it."""

Expand Down Expand Up @@ -193,7 +232,7 @@ def append_local_audit_event(
) -> dict[str, Any]:
"""Validate and append one event; this API never updates prior JSONL records."""

document = validate_local_audit_event(event)
document = validate_local_audit_event(sanitize_local_audit_event(event))
audit_path = Path(path)
audit_path.parent.mkdir(parents=True, exist_ok=True)
with audit_path.open("a", encoding="utf-8") as handle:
Expand All @@ -203,6 +242,45 @@ def append_local_audit_event(
return document


def _sanitize_audit_value(value: Any, *, key: object | None = None) -> Any:
if isinstance(key, str) and _is_private_audit_key(key):
return AUDIT_REDACTED_VALUE
if isinstance(value, Mapping):
return {
child_key: _sanitize_audit_value(child_value, key=child_key)
for child_key, child_value in value.items()
}
if isinstance(value, list):
return [_sanitize_audit_value(item) for item in value]
if isinstance(value, str):
return _sanitize_local_path(value)
return value


def _is_private_audit_key(key: str) -> bool:
normalized = re.sub(r"[^a-z0-9]", "", key.lower())
if normalized in _SENSITIVE_KEY_PARTS or normalized in _PRIVATE_CONTENT_KEYS:
return True
return any(part in normalized for part in _SENSITIVE_KEY_PARTS)


def _sanitize_local_path(value: str) -> str:
path_variants = (Path(value), PureWindowsPath(value))
if not value.startswith("~") and not any(
path.is_absolute() for path in path_variants
):
return _LOCAL_PATH_IN_TEXT.sub(
lambda match: _local_path_label(match.group()), value
)
return _local_path_label(value)


def _local_path_label(value: str) -> str:
path_variants = (Path(value), PureWindowsPath(value))
name = next((path.name for path in reversed(path_variants) if path.name), "")
return f"local-path/{name}" if name else "local-path"


def _require_text(value: object, label: str) -> None:
if not isinstance(value, str) or not value.strip():
raise LocalAuditEventError(f"{label} must be a non-empty string")
Expand Down
78 changes: 78 additions & 0 deletions tests/test_local_audit_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,91 @@

from aethermesh_core.local_audit_event import (
AUDIT_EVENT_TYPES,
AUDIT_REDACTED_VALUE,
LocalAuditEventError,
append_local_audit_event,
sanitize_local_audit_event,
validate_local_audit_event,
)


class LocalAuditEventTests(unittest.TestCase):
def test_sanitizes_secrets_private_content_and_absolute_paths(self) -> None:
event = {
**_referenced_event(),
"related_file_paths": ["/Users/private/node/manifests/work-001.json"],
"hashes": {
"result_hash": "sha256:example",
"apiKey": "private-api-key",
"service_api_key": "private-service-api-key",
"access_token": "private-token",
"environment": "HOME=/Users/private",
"environment_variables": "HOME=/Users/private",
},
"signatures": {
"privateKey": "private-key",
"signingPrivateKey": "private-signing-key",
"authorization": "Bearer private-token",
"credential": "private-credential",
"password": "private-password",
"secret": "private-secret",
"seed": "private-seed",
},
}

sanitized = sanitize_local_audit_event(event)

self.assertEqual(sanitized["related_file_paths"], ["local-path/work-001.json"])
self.assertEqual(sanitized["hashes"]["result_hash"], "sha256:example")
for field in (
"apiKey",
"service_api_key",
"access_token",
"environment",
"environment_variables",
):
self.assertEqual(sanitized["hashes"][field], AUDIT_REDACTED_VALUE)
for value in sanitized["signatures"].values():
self.assertEqual(value, AUDIT_REDACTED_VALUE)

def test_appended_sanitized_event_keeps_audit_attribution(self) -> None:
event = {
**_referenced_event(),
"related_file_paths": ["/home/private/aethermesh/work-001.json"],
"signatures": {"token": "must-not-be-written"},
}
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "events.jsonl"
written = append_local_audit_event(path, event)
output = path.read_text(encoding="utf-8")

self.assertNotIn("must-not-be-written", output)
self.assertNotIn("/home/private", output)
self.assertEqual(written["creator_node_id"], "creator-local-a")
self.assertEqual(written["manifest_id"], "manifest-work-001")
self.assertEqual(written["validation_receipt_id"], "receipt-work-001")
self.assertEqual(written["lineage_parent_ids"], ["work-parent-001"])
self.assertEqual(
written["contribution_attribution_ids"],
["local-contribution-work-001"],
)

def test_sanitizes_embedded_local_path_in_short_summary(self) -> None:
event = {
**_referenced_event(),
"error_summary": (
"could not read path:/Users/private/node/key.pem or "
"file:///home/private/node/backup.pem"
),
}

sanitized = sanitize_local_audit_event(event)

self.assertEqual(
sanitized["error_summary"],
"could not read path:local-path/key.pem or local-path/backup.pem",
)

def test_validates_required_fields_and_allows_missing_optional_fields(self) -> None:
event = _minimal_event()

Expand Down
Loading