diff --git a/agent-governance-python/agent-mesh/src/agentmesh/governance/audit.py b/agent-governance-python/agent-mesh/src/agentmesh/governance/audit.py index 9957c13f5..476a47451 100644 --- a/agent-governance-python/agent-mesh/src/agentmesh/governance/audit.py +++ b/agent-governance-python/agent-mesh/src/agentmesh/governance/audit.py @@ -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) @@ -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="") @@ -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, @@ -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, @@ -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) diff --git a/agent-governance-python/agent-mesh/tests/test_governance.py b/agent-governance-python/agent-mesh/tests/test_governance.py index 24ed3a54a..9776b0368 100644 --- a/agent-governance-python/agent-mesh/tests/test_governance.py +++ b/agent-governance-python/agent-mesh/tests/test_governance.py @@ -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.""" diff --git a/docs/specs/AUDIT-COMPLIANCE-1.0.md b/docs/specs/AUDIT-COMPLIANCE-1.0.md index 01cd8f131..2f611e2d0 100644 --- a/docs/specs/AUDIT-COMPLIANCE-1.0.md +++ b/docs/specs/AUDIT-COMPLIANCE-1.0.md @@ -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. | @@ -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: