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
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,30 @@ class AuditEntry(BaseModel):
event_type: str
agent_did: str
action: str
arguments_hash: str | None = Field(
default=None,
description=(
"SHA-256 hash (hex, lowercase) of the canonical-JSON serialization of "
"the action arguments. Defends downstream verifiers against silent "
"mutation of recorded arguments. NOT part of the canonical entry hash "
"in spec v1.0; v1.1 will extend MerkleAuditChain coverage. "
"See spec §4.3.1."
),
)

# Context
resource: Optional[str] = None
target_did: Optional[str] = None
approver_did: str | None = Field(
default=None,
description=(
"DID of the principal whose approval authorized this action. Surfaces "
"approval-chain identity in the audit row itself (independent of the "
"workflow approval subsystem). NOT part of the canonical entry hash "
"in spec v1.0; v1.1 will extend MerkleAuditChain coverage. "
"See spec §4.3.1."
),
)

# Data (sanitized - no secrets)
data: dict = Field(default_factory=dict)
Expand All @@ -84,6 +104,15 @@ class AuditEntry(BaseModel):
# Policy evaluation
policy_decision: Optional[str] = None
matched_rule: Optional[str] = None
policy_version: str | None = Field(
default=None,
description=(
"Version identifier of the policy bundle that produced this decision. "
"Defends against silent policy downgrade (replaying old decisions under "
"a newer policy version). NOT part of the canonical entry hash in spec "
"v1.0; v1.1 will extend MerkleAuditChain coverage. See spec §4.3.1."
),
)

# Chaining — populated automatically by MerkleAuditChain.add_entry()
previous_hash: str = Field(default="")
Expand Down Expand Up @@ -182,6 +211,9 @@ def to_cloudevent(self) -> dict[str, Any]:
"outcome": self.outcome,
"policy_decision": self.policy_decision,
"matched_rule": self.matched_rule,
**({"policy_version": self.policy_version} if self.policy_version else {}),
**({"arguments_hash": self.arguments_hash} if self.arguments_hash else {}),
**({"approver_did": self.approver_did} if self.approver_did else {}),
**self.data,
},
"agentmeshentryhash": self.entry_hash,
Expand Down Expand Up @@ -438,8 +470,18 @@ def log(
outcome: str = "success",
policy_decision: Optional[str] = None,
trace_id: Optional[str] = None,
*,
arguments_hash: str | None = None,
approver_did: str | None = None,
policy_version: str | None = None,
) -> AuditEntry:
"""Log an audit event."""
"""Log an audit event.

The ``arguments_hash``, ``approver_did``, and ``policy_version`` parameters
are accepted as keyword-only arguments to preserve the positional signature
for existing callers. See spec §4.3.1 for semantics and the v1.0/v1.1 hash
coverage caveat.
"""
entry = AuditEntry(
event_type=event_type,
agent_did=agent_did,
Expand All @@ -452,6 +494,9 @@ def log(
sandbox_id=self._env_context.sandbox_id,
environment=self._env_context.environment,
container_runtime=self._env_context.container_runtime,
arguments_hash=arguments_hash,
approver_did=approver_did,
policy_version=policy_version,
)

self._chain.add_entry(entry)
Expand Down
171 changes: 169 additions & 2 deletions agent-governance-python/agent-mesh/tests/test_governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,14 +248,181 @@ def test_chain_tamper_detection(self):
def test_audit_export(self):
"""Test exporting audit log for external verification."""
audit_log = AuditLog()

audit_log.log("action", "did:agentmesh:agent-1", "read")

export = audit_log.export()
assert export["entry_count"] == 1
assert export["chain_root"] is not None # Merkle root is computed


class TestAuditEntryExtensions:
"""Tests for v1.0 additive audit fields: arguments_hash, approver_did, policy_version.

These fields are recorded but are NOT yet part of the canonical hash computed
by ``AuditEntry.compute_hash()``; spec v1.1 will extend ``MerkleAuditChain``
coverage. See ``docs/specs/AUDIT-COMPLIANCE-1.0.md`` §4.3.1.
"""

def test_audit_entry_accepts_arguments_hash(self):
from agentmesh.governance.audit import AuditEntry

entry = AuditEntry(
event_type="tool_invocation",
agent_did="did:agentmesh:caller",
action="file_read",
arguments_hash="0" * 64,
)

assert entry.arguments_hash == "0" * 64

def test_audit_entry_accepts_approver_did(self):
from agentmesh.governance.audit import AuditEntry

entry = AuditEntry(
event_type="tool_invocation",
agent_did="did:agentmesh:caller",
action="wire_transfer",
approver_did="did:agentmesh:human-reviewer",
)

assert entry.approver_did == "did:agentmesh:human-reviewer"

def test_audit_entry_accepts_policy_version(self):
from agentmesh.governance.audit import AuditEntry

entry = AuditEntry(
event_type="policy_evaluation",
agent_did="did:agentmesh:caller",
action="evaluate",
policy_version="2026.04.01-abc1234",
)

assert entry.policy_version == "2026.04.01-abc1234"

def test_new_fields_default_to_none(self):
from agentmesh.governance.audit import AuditEntry

entry = AuditEntry(
event_type="action",
agent_did="did:agentmesh:test",
action="read",
)

assert entry.arguments_hash is None
assert entry.approver_did is None
assert entry.policy_version is None

def test_audit_log_log_passes_through_new_fields(self):
audit_log = AuditLog()

entry = audit_log.log(
event_type="tool_invocation",
agent_did="did:agentmesh:caller",
action="wire_transfer",
arguments_hash="abc123" + "0" * 58,
approver_did="did:agentmesh:human-reviewer",
policy_version="2026.04.01-abc1234",
)

assert entry.arguments_hash == "abc123" + "0" * 58
assert entry.approver_did == "did:agentmesh:human-reviewer"
assert entry.policy_version == "2026.04.01-abc1234"

def test_canonical_hash_unchanged_by_new_fields(self):
"""Backward compat: new fields MUST NOT affect ``compute_hash()`` in v1.0.

Spec §4.4 fixes the canonical hash field set. The v1.0 canonical form is
intentionally unchanged so that previously-persisted entries continue to
verify. Spec v1.1 will introduce hash coverage under an explicit
schema-version selector. See §4.3.1.
"""
from agentmesh.governance.audit import AuditEntry

fixed_ts = datetime(2026, 5, 22, 0, 0, 0, tzinfo=timezone.utc)

baseline = AuditEntry(
entry_id="audit_fixed_id_0001",
timestamp=fixed_ts,
event_type="action",
agent_did="did:agentmesh:test",
action="read",
)

extended = AuditEntry(
entry_id="audit_fixed_id_0001",
timestamp=fixed_ts,
event_type="action",
agent_did="did:agentmesh:test",
action="read",
arguments_hash="deadbeef" * 8,
approver_did="did:agentmesh:approver",
policy_version="v2.1.0",
)

assert extended.compute_hash() == baseline.compute_hash(), (
"v1.0 canonical hash form must not depend on additive fields; "
"see spec §4.3.1. Schema v1.1 will introduce hash coverage."
)

def test_cloudevent_serialization_includes_new_fields_when_set(self):
from agentmesh.governance.audit import AuditEntry

entry = AuditEntry(
event_type="tool_invocation",
agent_did="did:agentmesh:caller",
action="wire_transfer",
arguments_hash="cafe" * 16,
approver_did="did:agentmesh:human-reviewer",
policy_version="2026.04.01-abc1234",
)

envelope = entry.to_cloudevent()

assert envelope["data"]["arguments_hash"] == "cafe" * 16
assert envelope["data"]["approver_did"] == "did:agentmesh:human-reviewer"
assert envelope["data"]["policy_version"] == "2026.04.01-abc1234"

def test_cloudevent_serialization_omits_new_fields_when_unset(self):
from agentmesh.governance.audit import AuditEntry

entry = AuditEntry(
event_type="action",
agent_did="did:agentmesh:test",
action="read",
)

envelope = entry.to_cloudevent()

assert "arguments_hash" not in envelope["data"]
assert "approver_did" not in envelope["data"]
assert "policy_version" not in envelope["data"]

def test_chain_verification_with_new_fields(self):
"""Chain still verifies when new fields are populated alongside existing ones."""
audit_log = AuditLog()

audit_log.log(
event_type="tool_invocation",
agent_did="did:agentmesh:agent-1",
action="read",
arguments_hash="abc" + "0" * 61,
policy_version="v1.0.0",
)
audit_log.log(
event_type="tool_invocation",
agent_did="did:agentmesh:agent-1",
action="write",
arguments_hash="def" + "0" * 61,
approver_did="did:agentmesh:approver",
policy_version="v1.0.0",
)

is_valid, error = audit_log.verify_integrity()
assert is_valid is True
assert error is None


class TestShadowMode:
"""Tests for ShadowMode."""

Expand Down
21 changes: 21 additions & 0 deletions docs/specs/AUDIT-COMPLIANCE-1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,15 @@ The Agent Mesh audit entry extends the base schema with mesh-specific fields:
| `event_type` | string | REQUIRED | Category of the audit event. |
| `agent_did` | string | REQUIRED | DID of the agent. |
| `action` | string | REQUIRED | The action being audited. |
| `arguments_hash` | string | OPTIONAL | SHA-256 hash (hex, lowercase) of the canonical-JSON serialization of the action arguments. Defends against silent mutation of recorded arguments. See §4.3.1. |
| `resource` | string | OPTIONAL | Target resource of the action. |
| `target_did` | string | OPTIONAL | DID of the target agent (for inter-agent actions). |
| `approver_did` | string | OPTIONAL | DID of the principal whose approval authorized this action. Surfaces approval-chain identity in the audit row itself. See §4.3.1. |
| `data` | dict | OPTIONAL | Additional structured data. |
| `outcome` | string | OPTIONAL | Result of the action. Default: "success". |
| `policy_decision` | string | OPTIONAL | Policy engine decision. |
| `matched_rule` | string | OPTIONAL | Rule that matched. |
| `policy_version` | string | OPTIONAL | Version identifier of the policy bundle that produced this decision. Defends against silent policy downgrade. See §4.3.1. |
| `previous_hash` | string | OPTIONAL | Hash of the previous entry in the chain. |
| `entry_hash` | string | OPTIONAL | SHA-256 hash of this entry's canonical form. |
| `trace_id` | string | OPTIONAL | OTel trace ID for correlation. |
Expand All @@ -233,6 +236,24 @@ The Agent Mesh audit entry extends the base schema with mesh-specific fields:
| `environment` | string | OPTIONAL | Deployment environment name. |
| `compute_driver` | string | OPTIONAL | Compute driver identifier. |

### 4.3.1 Additive Tamper-Evidence Fields [Pure Specification]

The fields `arguments_hash`, `approver_did`, and `policy_version` are OPTIONAL
in spec v1.0 and serve verifiability purposes that are not yet covered by the
canonical entry hash defined in §4.4. In spec v1.0:

- Implementations MAY populate these fields. Verifiers MUST NOT treat their
presence or absence as a conformance signal.
- The canonical hash field set in §4.4 is intentionally unchanged from spec
v1.0.0 to preserve chain verification of previously-persisted entries.
- Because these fields are not in the canonical hash, a tampering party can
mutate them without invalidating `entry_hash`. Implementations and verifiers
MUST NOT rely on these fields for tamper detection in v1.0.

Spec v1.1 will extend the §4.4 canonical field set to include these fields under
an explicit schema-version selector, providing tamper-evident coverage while
preserving v1.0 verification semantics for legacy chains.

### 4.4 Entry Hash Computation [Pure Specification]

Implementations MUST compute entry hashes using the following algorithm:
Expand Down
Loading